diff --git a/.ado/ci.yml b/.ado/ci.yml index 1d42787e..7a943be2 100644 --- a/.ado/ci.yml +++ b/.ado/ci.yml @@ -60,8 +60,9 @@ jobs: displayName: Set Python version - script: | - pip install pytest pytest-azurepipelines pytest-cov pytest-xdist - displayName: Install pytest dependencies + python -m pip install --upgrade pip + pip install pytest pytest-azurepipelines pytest-cov pytest-xdist tox + displayName: Install test dependencies - script: | pip freeze @@ -69,9 +70,14 @@ jobs: - script: | cd $(Build.SourcesDirectory)/azure-quantum - pip install .[qiskit,cirq,qsharp,dev] - pytest --numprocesses $(PYTEST_MAX_PARALLEL_TESTS) --cov-report term --cov=azure.quantum --junitxml test-output-azure-quantum.xml $(Build.SourcesDirectory)/azure-quantum + pip install ".[cirq,qsharp,dev]" + pytest --numprocesses $(PYTEST_MAX_PARALLEL_TESTS) --cov-report term --cov=azure.quantum --junitxml test-output-azure-quantum.xml --ignore tests/unit/test_qiskit.py --ignore tests/unit/test_session_qiskit.py $(Build.SourcesDirectory)/azure-quantum displayName: Run azure-quantum unit tests + + - script: | + cd $(Build.SourcesDirectory)/azure-quantum + tox -e py311-qiskit1,py311-qiskit2 + displayName: Run Qiskit matrix tests - task: PublishTestResults@2 displayName: 'Publish tests results (python)' diff --git a/.ado/publish.yml b/.ado/publish.yml index 7dccaf09..cc2e6e8a 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -110,8 +110,9 @@ extends: displayName: Set Python version - script: | - pip install pytest pytest-azurepipelines pytest-cov pytest-xdist - displayName: Install pytest dependencies + python -m pip install --upgrade pip + pip install pytest pytest-azurepipelines pytest-cov pytest-xdist tox + displayName: Install test dependencies - script: | pip freeze @@ -119,10 +120,15 @@ extends: - script: | cd $(Build.SourcesDirectory)/azure-quantum - pip install .[qiskit,cirq,qsharp,dev] - pytest --numprocesses $(PYTEST_MAX_PARALLEL_TESTS) --cov-report term --cov=azure.quantum --junitxml test-output-azure-quantum.xml $(Build.SourcesDirectory)/azure-quantum + pip install ".[cirq,qsharp,dev]" + pytest --numprocesses $(PYTEST_MAX_PARALLEL_TESTS) --cov-report term --cov=azure.quantum --junitxml test-output-azure-quantum.xml --ignore tests/unit/test_qiskit.py --ignore tests/unit/test_session_qiskit.py $(Build.SourcesDirectory)/azure-quantum displayName: Run Unit-tests + - script: | + cd $(Build.SourcesDirectory)/azure-quantum + tox -e py311-qiskit1,py311-qiskit2 + displayName: Run Qiskit matrix tests + - task: PublishTestResults@2 displayName: 'Publish test results (python)' condition: succeededOrFailed() diff --git a/.gitignore b/.gitignore index dc240b94..e6dfc4d9 100644 --- a/.gitignore +++ b/.gitignore @@ -391,7 +391,6 @@ coverage.xml htmlcov/ # Other -*.ini dist/ temp/ .[v]env/ diff --git a/NOTICE b/NOTICE index 7ea25c0b..0797433d 100644 --- a/NOTICE +++ b/NOTICE @@ -22,6 +22,20 @@ required to debug changes to any libraries licensed under the GNU Lesser General --------------------------------------------------------- +qiskit-ionq helpers and exceptions (commit cea8f9874b992f82a35648582c06958869370c69) - Apache-2.0 + +Portions of azure.quantum.qiskit.backends._qiskit_ionq.py and azure-quantum/tests/unit/test_qiskit.py +are adapted from qiskit-community/qiskit-ionq (ionq_gates.py, helpers.py, and exceptions.py) to +support Qiskit BackendV2. +Copyright 2017-2018 IBM and 2020-2021 IonQ, Inc. + +Licensed under the Apache License, Version 2.0. A copy of the license text +appears within this NOTICE file and is available at http://www.apache.org/licenses/LICENSE-2.0. + +--------------------------------------------------------- + +--------------------------------------------------------- + asttokens 2.0.5 - Apache-2.0 diff --git a/azure-quantum/azure/quantum/qiskit/backends/_qiskit_ionq.py b/azure-quantum/azure/quantum/qiskit/backends/_qiskit_ionq.py new file mode 100644 index 00000000..0c4ffc2c --- /dev/null +++ b/azure-quantum/azure/quantum/qiskit/backends/_qiskit_ionq.py @@ -0,0 +1,377 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2018. +# +# 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. + +# Copyright 2020 IonQ, Inc. (www.ionq.com) +# +# 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. + +# This code is copied from: +# - +# - +# to enable Qiskit 1 & 2 compatibility as the original package only +# supports Qiskit 1 OR 2 depending on the package version. +# Modified by Microsoft Corporation for Azure Quantum integration and BackendV2 support (2025-11-10). + +# the qiskit gates that the IonQ backend can serialize to our IR +# not the actual hardware basis gates for the system — we do our own transpilation pass. +# also not an exact/complete list of the gates IonQ's backend takes +# by name — please refer to IonQ docs for that. +# +# Some of these gates may be deprecated or removed in qiskit 1.0 +ionq_basis_gates = [ + "ccx", + "ch", + "cnot", + "cp", + "crx", + "cry", + "crz", + "csx", + "cx", + "cy", + "cz", + "h", + "i", + "id", + "mcp", + "mcphase", + "mct", + "mcx", + "measure", + "p", + "rx", + "rxx", + "ry", + "ryy", + "rz", + "rzz", + "s", + "sdg", + "swap", + "sx", + "sxdg", + "t", + "tdg", + "toffoli", + "x", + "y", + "z", + "PauliEvolution", +] + +# https://ionq.com/docs/getting-started-with-native-gates +ionq_native_basis_gates = [ + "gpi", + "gpi2", + "ms", # Pairwise MS gate + "zz", # ZZ gate +] + +# Each language corresponds to a different set of basis gates. +GATESET_MAP = { + "qis": ionq_basis_gates, + "native": ionq_native_basis_gates, +} + +ionq_api_aliases = { # todo fix alias bug + "cp": "cz", + "csx": "cv", + "mcphase": "cz", + "ccx": "cx", # just one C for all mcx + "mcx": "cx", # just one C for all mcx + "tdg": "ti", + "p": "z", + "PauliEvolution": "pauliexp", + "rxx": "xx", + "ryy": "yy", + "rzz": "zz", + "sdg": "si", + "sx": "v", + "sxdg": "vi", +} + +from qiskit.circuit import ( + controlledgate as q_cgates, + QuantumCircuit, +) + +from typing import Literal, Any + +from qiskit.exceptions import QiskitError + +class IonQError(QiskitError): + """Base class for errors raised by an IonQProvider.""" + + def __str__(self) -> str: + return f"{self.__class__.__name__}({self.message!r})" + + def __repr__(self) -> str: + return repr(str(self)) + +class JobError(QiskitError): + """Base class for errors raised by Jobs.""" + + pass + +class IonQGateError(IonQError, JobError): + """Errors generated from invalid gate defs + + Attributes: + gate_name: The name of the gate which caused this error. + """ + + def __init__(self, gate_name: str, gateset: Literal["qis", "native"]): + self.gate_name = gate_name + self.gateset = gateset + super().__init__( + ( + f"gate '{gate_name}' is not supported on the '{gateset}' IonQ backends. " + "Please use the qiskit.transpile method, manually rewrite to remove the gate, " + "or change the gateset selection as appropriate." + ) + ) + + def __repr__(self): + return f"{self.__class__.__name__}(gate_name={self.gate_name!r}, gateset={self.gateset!r})" + +class IonQMidCircuitMeasurementError(IonQError, JobError): + """Errors generated from attempting mid-circuit measurement, which is not supported. + Measurement must come after all instructions. + + Attributes: + qubit_index: The qubit index to be measured mid-circuit + """ + + def __init__(self, qubit_index: int, gate_name: str): + self.qubit_index = qubit_index + self.gate_name = gate_name + super().__init__( + f"Attempting to put '{gate_name}' after a measurement on qubit {qubit_index}. " + "Mid-circuit measurement is not supported." + ) + + def __str__(self): + kwargs = f"qubit_index={self.qubit_index!r}, gate_name={self.gate_name!r}" + return f"{self.__class__.__name__}({kwargs})" + +class IonQPauliExponentialError(IonQError): + """Errors generated from improper usage of Pauli exponentials.""" + +def paulis_commute(pauli_terms: list[str]) -> bool: + """Check if a list of Pauli terms commute. + + Args: + pauli_terms (list): A list of Pauli terms. + + Returns: + bool: Whether the Pauli terms commute. + """ + for i, term in enumerate(pauli_terms): + for other_term in pauli_terms[i:]: + assert len(term) == len(other_term) + anticommutation_parity = 0 + for index, char in enumerate(term): + other_char = other_term[index] + if "I" not in (char, other_char): + if char != other_char: + anticommutation_parity += 1 + if anticommutation_parity % 2 == 1: + return False + return True + +def qiskit_circ_to_ionq_circ( + input_circuit: QuantumCircuit, + gateset: Literal["qis", "native"] = "qis", + ionq_compiler_synthesis: bool = False, +): + """Build a circuit in IonQ's instruction format from qiskit instructions. + + .. ATTENTION:: This function ignores the following compiler directives: + * ``barrier`` + + Parameters: + input_circuit (:class:`qiskit.circuit.QuantumCircuit`): A Qiskit quantum circuit. + gateset (string): Set of gates to target. It can be QIS (required transpilation pass in + IonQ backend, which is sent standard gates) or native (only IonQ native gates are + allowed, in the future we may provide transpilation to these gates in Qiskit). + ionq_compiler_synthesis (bool): Whether to opt-in to IonQ compiler's intelligent + trotterization. + + Raises: + IonQGateError: If an unsupported instruction is supplied. + IonQMidCircuitMeasurementError: If a mid-circuit measurement is detected. + IonQPauliExponentialError: If non-commuting PauliExponentials are found without + the appropriate flag. + + Returns: + list[dict]: A list of instructions in a converted dict format. + int: The number of measurements. + dict: The measurement map from qubit number to classical bit number. + """ + compiler_directives = ["barrier"] + output_circuit = [] + num_meas = 0 + meas_map = [None] * len(input_circuit.clbits) + for inst in input_circuit.data: + instruction, qargs, cargs = inst.operation, inst.qubits, inst.clbits + + # Don't process compiler directives. + instruction_name = instruction.name + if instruction_name in compiler_directives: + continue + + # Don't process measurement instructions. + if instruction_name == "measure": + meas_map[input_circuit.clbits.index(cargs[0])] = input_circuit.qubits.index( + qargs[0] + ) + num_meas += 1 + continue + + # serialized identity gate is a no-op + if instruction_name == "id": + continue + + # Raise out for instructions we don't support. + if instruction_name not in GATESET_MAP[gateset]: + raise IonQGateError(instruction_name, gateset) + + # Process the instruction and convert. + rotation: dict[str, Any] = {} + if len(instruction.params) > 0: + if gateset == "qis" or ( + len(instruction.params) == 1 and instruction_name != "zz" + ): + # The float is here to cast Qiskit ParameterExpressions to numbers + rotation = { + ("rotation" if gateset == "qis" else "phase"): float( + instruction.params[0] + ) + } + if instruction_name == "PauliEvolution": + # rename rotation to time + rotation["time"] = rotation.pop("rotation") + elif instruction_name in {"zz"}: + rotation = {"angle": instruction.params[0]} + else: + rotation = { + "phases": [float(t) for t in instruction.params[:2]], + "angle": instruction.params[2], + } + + # Default conversion is simple, just gate & target(s). + targets = [input_circuit.qubits.index(qargs[0])] + if instruction_name in {"ms", "zz"}: + targets.append(input_circuit.qubits.index(qargs[1])) + + converted = ( + {"gate": instruction_name, "targets": targets} + if instruction_name not in {"gpi", "gpi2"} + else { + "gate": instruction_name, + "target": targets[0], + } + ) + + # re-alias certain names + if instruction_name in ionq_api_aliases: + instruction_name = ionq_api_aliases[instruction_name] + converted["gate"] = instruction_name + + # Make sure uncontrolled multi-targets use all qargs. + if instruction.num_qubits > 1 and not hasattr(instruction, "num_ctrl_qubits"): + converted["targets"] = [ + input_circuit.qubits.index(qargs[i]) + for i in range(instruction.num_qubits) + ] + + # If this is a controlled gate, make sure to set control qubits. + if isinstance(instruction, q_cgates.ControlledGate): + gate = instruction_name[1:] # trim the leading c + controls = [input_circuit.qubits.index(qargs[0])] + targets = [input_circuit.qubits.index(qargs[1])] + # If this is a multi-control, use more than one qubit. + if instruction.num_ctrl_qubits > 1: + controls = [ + input_circuit.qubits.index(qargs[i]) + for i in range(instruction.num_ctrl_qubits) + ] + targets = [ + input_circuit.qubits.index(qargs[instruction.num_ctrl_qubits]) + ] + if gate == "swap": + # If this is a cswap, we have two targets: + targets = [ + input_circuit.qubits.index(qargs[-2]), + input_circuit.qubits.index(qargs[-1]), + ] + + # Update converted gate values. + converted.update( + { + "gate": gate, + "controls": controls, + "targets": targets, + } + ) + + if instruction_name == "pauliexp": + imag_coeff = any(coeff.imag for coeff in instruction.operator.coeffs) + assert not imag_coeff, ( + "PauliEvolution gate must have real coefficients, " + f"but got {imag_coeff}" + ) + terms = [term[0] for term in instruction.operator.to_list()] + if not ionq_compiler_synthesis and not paulis_commute(terms): + raise IonQPauliExponentialError( + f"You have included a PauliEvolutionGate with non-commuting terms: {terms}." + "To decompose it with IonQ hardware-aware synthesis, resubmit with the " + "IONQ_COMPILER_SYNTHESIS flag." + ) + targets = [ + input_circuit.qubits.index(qargs[i]) + for i in range(instruction.num_qubits) + ] + coefficients = [coeff.real for coeff in instruction.operator.coeffs] + gate = { + "gate": instruction_name, + "targets": targets, + "terms": terms, + "coefficients": coefficients, + } + converted.update(gate) + + # if there's a valid instruction after a measurement, + if num_meas > 0: + # see if any of the involved qubits have been measured, + # and raise if so — no mid-circuit measurement! + controls_and_targets = converted.get("targets", []) + converted.get( + "controls", [] + ) + if any(i in meas_map for i in controls_and_targets): + raise IonQMidCircuitMeasurementError( + input_circuit.qubits.index(qargs[0]), instruction_name + ) + + output_circuit.append({**converted, **rotation}) + + return output_circuit, num_meas, meas_map diff --git a/azure-quantum/azure/quantum/qiskit/backends/backend.py b/azure-quantum/azure/quantum/qiskit/backends/backend.py index b650b5ed..b6df618f 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/backend.py +++ b/azure-quantum/azure/quantum/qiskit/backends/backend.py @@ -2,6 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. ## +import copy import os import json @@ -10,32 +11,51 @@ logger = logging.getLogger(__name__) -from typing import Any, Dict, Tuple, Union, List, Optional +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, Dict, Union, List, Optional, TYPE_CHECKING, Tuple, Mapping from azure.quantum.version import __version__ from azure.quantum.qiskit.job import ( MICROSOFT_OUTPUT_DATA_FORMAT, - MICROSOFT_OUTPUT_DATA_FORMAT_V2, AzureQuantumJob, ) from abc import abstractmethod from azure.quantum.job.session import SessionHost +BackendConfigurationType = None +QOBJ_TYPES: Tuple[type, ...] = tuple() + +if TYPE_CHECKING: + from azure.quantum import Workspace + from azure.quantum.qiskit import AzureQuantumProvider + try: from qiskit import QuantumCircuit - from qiskit.providers import BackendV1 as Backend + from qiskit.providers import BackendV2 as Backend from qiskit.providers import Options - from qiskit.providers import Provider - from qiskit.providers.models import BackendConfiguration - from qiskit.qobj import QasmQobj, PulseQobj - import pyqir as pyqir + from qiskit.transpiler import Target + from qiskit.circuit import Instruction, Parameter + from qiskit.circuit.library.standard_gates import get_standard_gate_name_mapping from qsharp.interop.qiskit import QSharpBackend from qsharp import TargetProfile - -except ImportError: +except ImportError as exc: raise ImportError( "Missing optional 'qiskit' dependencies. \ To install run: pip install azure-quantum[qiskit]" - ) + ) from exc + +try: # Qiskit 1.x legacy support + from qiskit.providers.models import BackendConfiguration # type: ignore + BackendConfigurationType = BackendConfiguration + + from qiskit.qobj import QasmQobj, PulseQobj # type: ignore +except ImportError: # Qiskit 2.0 removes qobj module + QasmQobj = None # type: ignore + PulseQobj = None # type: ignore + +QOBJ_TYPES = tuple( + obj_type for obj_type in (QasmQobj, PulseQobj) if obj_type is not None +) # barrier is handled by an extra flag which will transpile # them away if the backend doesn't support them. This has @@ -59,6 +79,7 @@ "crz", "h", "s", + "sx", "sdg", "swap", "t", @@ -71,6 +92,200 @@ ] +@lru_cache(maxsize=None) +def _standard_gate_map() -> Dict[str, Instruction]: + mapping = get_standard_gate_name_mapping() + # Include both canonical and lowercase keys for easier lookup + lowered = {name.lower(): gate for name, gate in mapping.items()} + combined = {**mapping, **lowered} + return combined + + +def _custom_instruction_builders() -> Dict[str, Instruction]: + """Provide Instruction stubs for backend-specific gates. + + Azure Quantum targets expose native operations (for example, IonQ's + GPI-family gates or Quantinuum's multi-controlled primitives) that are not + part of Qiskit's standard gate catalogue. When we build a Target instance we + still need Instruction objects for these names so transpilation and circuit + validation can succeed. This helper returns lightweight Instruction + definitions that mirror each provider's gate signatures, ensuring + ``Target.add_instruction`` has the metadata it requires even though the + operations themselves are executed remotely. + """ + param = Parameter + return { + "gpi": Instruction("gpi", 1, 0, params=[param("phi")]), + "gpi2": Instruction("gpi2", 1, 0, params=[param("phi")]), + "ms": Instruction( + "ms", + 2, + 0, + params=[param("phi0"), param("phi1"), param("angle")], + ), + "zz": Instruction("zz", 2, 0, params=[param("angle")]), + "v": Instruction("v", 1, 0, params=[]), + "vdg": Instruction("vdg", 1, 0, params=[]), + "vi": Instruction("vi", 1, 0, params=[]), + "si": Instruction("si", 1, 0, params=[]), + "ti": Instruction("ti", 1, 0, params=[]), + "mcp": Instruction("mcp", 3, 0, params=[param("angle")]), + "mcphase": Instruction("mcphase", 3, 0, params=[param("angle")]), + "mct": Instruction("mct", 3, 0, params=[]), + "mcx": Instruction("mcx", 3, 0, params=[]), + "mcx_gray": Instruction("mcx_gray", 3, 0, params=[]), + "pauliexp": Instruction("pauliexp", 1, 0, params=[param("time")]), + "paulievolution": Instruction("PauliEvolution", 1, 0, params=[param("time")]), + } + + +def _resolve_instruction(gate_name: str) -> Optional[Instruction]: + mapping = _standard_gate_map() + instruction = mapping.get(gate_name) + if instruction is not None: + return instruction.copy() + + lower_name = gate_name.lower() + instruction = mapping.get(lower_name) + if instruction is not None: + return instruction.copy() + + custom_map = _custom_instruction_builders() + if gate_name in custom_map: + return custom_map[gate_name] + if lower_name in custom_map: + return custom_map[lower_name] + + # Default to a single-qubit placeholder instruction. + return Instruction(gate_name, 1, 0, params=[]) + + +@dataclass +class AzureBackendConfig: + """Lightweight configuration container for Azure Quantum backends.""" + + backend_name: Optional[str] = None + backend_version: Optional[str] = None + description: Optional[str] = None + n_qubits: Optional[int] = None + dt: Optional[float] = None + basis_gates: Tuple[str, ...] = field(default_factory=tuple) + azure: Dict[str, Any] = field(default_factory=dict) + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.azure = copy.deepcopy(self.azure or {}) + self.metadata = dict(self.metadata or {}) + if self.basis_gates is None: + self.basis_gates = tuple() + else: + self.basis_gates = tuple(self.basis_gates) + + @property + def name(self) -> Optional[str]: + return self.backend_name + + @property + def num_qubits(self) -> Optional[int]: + """Backward-compatible alias for Qiskit's ``BackendConfiguration.num_qubits``.""" + return self.n_qubits + + @num_qubits.setter + def num_qubits(self, value: Optional[int]) -> None: + self.n_qubits = value + + def get(self, key: str, default: Any = None) -> Any: + if key == "basis_gates": + return list(self.basis_gates) + if key == "azure": + return copy.deepcopy(self.azure) + if hasattr(self, key): + return getattr(self, key) + return self.metadata.get(key, default) + + def __getattr__(self, name: str) -> Any: + if name == "max_experiments": + return 1 + try: + return self.__dict__[name] + except KeyError as exc: + if name in self.metadata: + return self.metadata[name] + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) from exc + + def to_dict(self) -> Dict[str, Any]: + config_dict: Dict[str, Any] = { + "backend_name": self.backend_name, + "backend_version": self.backend_version, + "description": self.description, + "max_experiments": 1, + "n_qubits": self.n_qubits, + "dt": self.dt, + "basis_gates": list(self.basis_gates), + } + + config_dict.update(self.metadata) + + if self.azure: + config_dict["azure"] = copy.deepcopy(self.azure) + + return config_dict + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "AzureBackendConfig": + raw = dict(data) + azure_config = copy.deepcopy(raw.get("azure", {})) + basis_gates = raw.get("basis_gates") or [] + + known_keys = { + "backend_name", + "backend_version", + "description", + "n_qubits", + "dt", + "basis_gates", + "azure", + } + + metadata = {k: v for k, v in raw.items() if k not in known_keys} + + return cls( + backend_name=raw.get("backend_name"), + backend_version=raw.get("backend_version"), + description=raw.get("description"), + n_qubits=raw.get("n_qubits"), + dt=raw.get("dt"), + basis_gates=tuple(basis_gates), + azure=azure_config, + metadata=metadata, + ) + + @classmethod + def from_backend_configuration( + cls, configuration: Any + ) -> "AzureBackendConfig": + return cls.from_dict(configuration.to_dict()) + + +def _ensure_backend_config( + configuration: Any +) -> AzureBackendConfig: + if isinstance(configuration, AzureBackendConfig): + return configuration + + if BackendConfigurationType is not None and isinstance( + configuration, BackendConfigurationType + ): + return AzureBackendConfig.from_backend_configuration(configuration) + + if isinstance(configuration, Mapping): + return AzureBackendConfig.from_dict(configuration) + + raise TypeError("Unsupported configuration type for Azure backends") + + class AzureBackendBase(Backend, SessionHost): # Name of the provider's input parameter which specifies number of shots for a submitted job. @@ -80,11 +295,50 @@ class AzureBackendBase(Backend, SessionHost): @abstractmethod def __init__( self, - configuration: BackendConfiguration, - provider: Provider = None, + configuration: Any, + provider: "AzureQuantumProvider" = None, **fields ): - super().__init__(configuration, provider, **fields) + if configuration is None: + raise ValueError("Backend configuration is required for Azure backends") + + if BackendConfigurationType is not None and isinstance( + configuration, BackendConfigurationType + ): + warnings.warn( + "The BackendConfiguration parameter is deprecated and will be removed from the SDK.", + DeprecationWarning, + stacklevel=2, + ) + + config = _ensure_backend_config(configuration) + + self._config = config + + super().__init__( + provider=provider, + name=config.backend_name, + description=config.description, + backend_version=config.backend_version, + **fields, + ) + + self._target = self._build_target(config) + + def _build_target(self, configuration: AzureBackendConfig) -> Target: + num_qubits = configuration.n_qubits + target = Target( + description=configuration.description, + num_qubits=num_qubits, + dt=configuration.dt, + ) + + basis_gates: List[str] = list(configuration.basis_gates or []) + for gate_name in dict.fromkeys(basis_gates + ["measure", "reset"]): + instruction = _resolve_instruction(gate_name) + target.add_instruction(instruction) + + return target @abstractmethod def run( @@ -101,7 +355,7 @@ def run( Args: run_input (QuantumCircuit or List[QuantumCircuit]): An individual or a - list of :class:`~qiskit.circuits.QuantumCircuit` to run on the backend. + list of one :class:`~qiskit.circuits.QuantumCircuit` to run on the backend. shots (int, optional): Number of shots, defaults to None. options: Any kwarg options to pass to the backend for running the config. If a key is also present in the options @@ -130,25 +384,32 @@ def _default_options(cls) -> Options: def _azure_config(self) -> Dict[str, str]: pass + def configuration(self) -> AzureBackendConfig: + warnings.warn( + "AzureBackendBase.configuration() is deprecated and will be removed from the SDK.", + DeprecationWarning, + stacklevel=2, + ) + return self._config + + @property + def target(self) -> Target: + return self._target + + @property + def max_circuits(self) -> Optional[int]: + return 1 def retrieve_job(self, job_id) -> AzureQuantumJob: """Returns the Job instance associated with the given id.""" - return self._provider.get_job(job_id) + return self.provider.get_job(job_id) def _get_output_data_format(self, options: Dict[str, Any] = {}) -> str: - config: BackendConfiguration = self.configuration() - # output data format default depends on the number of experiments. QIR backends - # that don't define a default in their azure config will use this value - # Once more than one experiment is supported, we should always use the v2 format - default_output_data_format = ( - MICROSOFT_OUTPUT_DATA_FORMAT - if config.max_experiments == 1 - else MICROSOFT_OUTPUT_DATA_FORMAT_V2 - ) + config: AzureBackendConfig = self._config - azure_config: Dict[str, Any] = config.azure + azure_config: Dict[str, Any] = config.azure or {} # if the backend defines an output format, use that over the default azure_defined_override = azure_config.get( - "output_data_format", default_output_data_format + "output_data_format", MICROSOFT_OUTPUT_DATA_FORMAT ) # if the user specifies an output format, use that over the default azure config output_data_format = options.pop("output_data_format", azure_defined_override) @@ -171,7 +432,8 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ if shots is not None and options_shots is not None: warnings.warn( f"Parameter 'shots' conflicts with the '{self.__class__._SHOTS_PARAM_NAME}' parameter. " - "Please, provide only one option for setting shots. Defaulting to 'shots' parameter." + "Please, provide only one option for setting shots. Defaulting to 'shots' parameter.", + stacklevel=3, ) final_shots = shots @@ -180,7 +442,8 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ elif options_shots is not None: warnings.warn( f"Parameter '{self.__class__._SHOTS_PARAM_NAME}' is subject to change in future versions. " - "Please, use 'shots' parameter instead." + "Please, use 'shots' parameter instead.", + stacklevel=3, ) final_shots = options_shots @@ -213,15 +476,16 @@ def _get_input_params(self, options: Dict[str, Any], shots: int = None) -> Dict[ return input_params def _run(self, job_name, input_data, input_params, metadata, **options): - logger.info(f"Submitting new job for backend {self.name()}") + logger.info(f"Submitting new job for backend {self.name}") # The default of these job parameters come from the AzureBackend configuration: - config = self.configuration() - blob_name = options.pop("blob_name", config.azure["blob_name"]) - content_type = options.pop("content_type", config.azure["content_type"]) - provider_id = options.pop("provider_id", config.azure["provider_id"]) + config = self._config + azure_config = config.azure or {} + blob_name = options.pop("blob_name", azure_config.get("blob_name")) + content_type = options.pop("content_type", azure_config.get("content_type")) + provider_id = options.pop("provider_id", azure_config.get("provider_id")) input_data_format = options.pop( - "input_data_format", config.azure["input_data_format"] + "input_data_format", azure_config.get("input_data_format") ) output_data_format = self._get_output_data_format(options) @@ -238,13 +502,13 @@ def _run(self, job_name, input_data, input_params, metadata, **options): message += "To find a QIR capable backend, use the following code:" message += os.linesep message += ( - f'\tprovider.get_backend("{self.name()}", input_data_format: "qir.v1").' + f'\tprovider.get_backend("{self.name}", input_data_format: "qir.v1").' ) raise ValueError(message) job = AzureQuantumJob( backend=self, - target=self.name(), + target=self.name, name=job_name, input_data=input_data, blob_name=blob_name, @@ -292,10 +556,10 @@ def _normalize_run_input_params(self, run_input, **options): raise ValueError("No input provided.") def _get_azure_workspace(self) -> "Workspace": - return self.provider().get_workspace() + return self.provider.get_workspace() def _get_azure_target_id(self) -> str: - return self.name() + return self.name def _get_azure_provider_id(self) -> str: return self._azure_config()["provider_id"] @@ -304,7 +568,10 @@ def _get_azure_provider_id(self) -> str: class AzureQirBackend(AzureBackendBase): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, + configuration: AzureBackendConfig, + provider: "AzureQuantumProvider" = None, + **fields, ): super().__init__(configuration, provider, **fields) @@ -334,7 +601,7 @@ def run( Args: run_input (QuantumCircuit or List[QuantumCircuit]): An individual or a - list of :class:`~qiskit.circuits.QuantumCircuit` to run on the backend. + list of one :class:`~qiskit.circuits.QuantumCircuit` to run on the backend. shots (int, optional): Number of shots, defaults to None. options: Any kwarg options to pass to the backend for running the config. If a key is also present in the options @@ -349,17 +616,16 @@ def run( options.pop("run_input", None) options.pop("circuit", None) - circuits = list([]) - if isinstance(run_input, QuantumCircuit): - circuits = [run_input] - else: - circuits = run_input - - max_circuits_per_job = self.configuration().max_experiments - if len(circuits) > max_circuits_per_job: - raise NotImplementedError( - f"This backend only supports running a maximum of {max_circuits_per_job} circuits per job." - ) + circuit = run_input + if isinstance(run_input, list): + # just in case they passed a list, we only support single-experiment jobs + if len(run_input) != 1: + raise NotImplementedError( + f"This backend only supports running a single circuit per job." + ) + circuit = run_input[0] + if not isinstance(circuit, QuantumCircuit): + raise ValueError("Invalid input: expected a QuantumCircuit.") # config normalization input_params = self._get_input_params(options, shots=shots) @@ -369,53 +635,38 @@ def run( if self._can_send_shots_input_param(): shots_count = input_params.get(self.__class__._SHOTS_PARAM_NAME) - job_name = "" - if len(circuits) > 1: - job_name = f"batch-{len(circuits)}" - if shots_count is not None: - job_name = f"{job_name}-{shots_count}" - else: - job_name = circuits[0].name - job_name = options.pop("job_name", job_name) + job_name = options.pop("job_name", circuit.name) - metadata = options.pop("metadata", self._prepare_job_metadata(circuits)) + metadata = options.pop("metadata", self._prepare_job_metadata(circuit)) - input_data = self._translate_input(circuits, input_params) + input_data = self._translate_input(circuit, input_params) job = super()._run(job_name, input_data, input_params, metadata, **options) logger.info( - f"Submitted job with id '{job.id()}' with shot count of {shots_count}:" + "Submitted job with id '%s' with shot count of %s:", + job.id(), + shots_count, ) return job - def _prepare_job_metadata(self, circuits: List[QuantumCircuit]) -> Dict[str, str]: + def _prepare_job_metadata(self, circuit: QuantumCircuit) -> Dict[str, str]: """Returns the metadata relative to the given circuits that will be attached to the Job""" - if len(circuits) == 1: - circuit: QuantumCircuit = circuits[0] - return { - "qiskit": str(True), - "name": circuit.name, - "num_qubits": circuit.num_qubits, - "metadata": json.dumps(circuit.metadata), - } - # for batch jobs, we don't want to store the metadata of each circuit - # we fill out the result header in output processing. - # These headers don't matter for execution are are only used for - # result processing. - return {} - - def _generate_qir( - self, circuits: List[QuantumCircuit], target_profile: TargetProfile, **kwargs - ) -> pyqir.Module: - - if len(circuits) == 0: - raise ValueError("No QuantumCircuits provided") - - config = self.configuration() + return { + "qiskit": str(True), + "name": circuit.name, + "num_qubits": circuit.num_qubits, + "metadata": json.dumps(circuit.metadata), + } + + + def _get_qir_str( + self, circuit: QuantumCircuit, target_profile: TargetProfile, **kwargs + ) -> str: + config = self._config # Barriers aren't removed by transpilation and must be explicitly removed in the Qiskit to QIR translation. supports_barrier = "barrier" in config.basis_gates - skip_transpilation = kwargs.pop("skip_transpilation", False) + skip_transpilation = kwargs.pop("skip_transpilation", True) backend = QSharpBackend( qiskit_pass_options={"supports_barrier": supports_barrier}, @@ -424,73 +675,42 @@ def _generate_qir( **kwargs, ) - name = "batch" - if len(circuits) == 1: - name = circuits[0].name + qir_str = backend.qir(circuit) + + return qir_str - if isinstance(circuits, list): - for value in circuits: - if not isinstance(value, QuantumCircuit): - raise ValueError("Input must be List[QuantumCircuit]") - else: - raise ValueError("Input must be List[QuantumCircuit]") - - context = pyqir.Context() - llvm_module = pyqir.qir_module(context, name) - for circuit in circuits: - qir_str = backend.qir(circuit) - module = pyqir.Module.from_ir(context, qir_str) - entry_point = next(filter(pyqir.is_entry_point, module.functions)) - entry_point.name = circuit.name - llvm_module.link(module) - err = llvm_module.verify() - if err is not None: - raise Exception(err) - - return llvm_module - - def _get_qir_str( - self, - circuits: List[QuantumCircuit], - target_profile: TargetProfile, - **to_qir_kwargs, - ) -> str: - module = self._generate_qir(circuits, target_profile, **to_qir_kwargs) - return str(module) def _translate_input( - self, circuits: Union[QuantumCircuit, List[QuantumCircuit]], input_params: Dict[str, Any] + self, circuit: QuantumCircuit, input_params: Dict[str, Any] ) -> bytes: """Translates the input values to the QIR expected by the Backend.""" logger.info(f"Using QIR as the job's payload format.") - if not (isinstance(circuits, list)): - circuits = [circuits] target_profile = self._get_target_profile(input_params) if logger.isEnabledFor(logging.DEBUG): - qir = self._get_qir_str(circuits, target_profile, skip_transpilation=True) + qir = self._get_qir_str(circuit, target_profile, skip_transpilation=True) logger.debug(f"QIR:\n{qir}") - # We'll transpile automatically to the supported gates in QIR unless explicitly skipped. - skip_transpilation = input_params.pop("skipTranspile", False) - - module = self._generate_qir( - circuits, target_profile, skip_transpilation=skip_transpilation + skip_transpilation = input_params.pop("skipTranspile", True) + if not skip_transpilation: + warnings.warn( + "Transpilation is deprecated and will be removed in future versions. " + "Please, transpile circuits manually before passing them to the backend.", + category=DeprecationWarning, + stacklevel=3, + ) + + qir_str = self._get_qir_str( + circuit, target_profile, skip_transpilation=skip_transpilation ) - def get_func_name(func: pyqir.Function) -> str: - return func.name - - entry_points = list( - map(get_func_name, filter(pyqir.is_entry_point, module.functions)) - ) + entry_points = ["ENTRYPOINT__main"] if not skip_transpilation: # We'll only log the QIR again if we performed a transpilation. if logger.isEnabledFor(logging.DEBUG): - qir = str(module) - logger.debug(f"QIR (Post-transpilation):\n{qir}") + logger.debug(f"QIR (Post-transpilation):\n{qir_str}") if "items" not in input_params: arguments = input_params.pop("arguments", []) @@ -498,7 +718,7 @@ def get_func_name(func: pyqir.Function) -> str: {"entryPoint": name, "arguments": arguments} for name in entry_points ] - return str(module).encode("utf-8") + return qir_str.encode("utf-8") def _get_target_profile(self, input_params) -> TargetProfile: # Default to Adaptive_RI if not specified on the backend @@ -528,7 +748,10 @@ class AzureBackend(AzureBackendBase): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, + configuration: AzureBackendConfig, + provider: "AzureQuantumProvider" = None, + **fields, ): super().__init__(configuration, provider, **fields) @@ -567,7 +790,7 @@ def run( # If the circuit was created using qiskit.assemble, # disassemble into QASM here - if isinstance(circuit, QasmQobj) or isinstance(circuit, PulseQobj): + if QOBJ_TYPES and isinstance(circuit, QOBJ_TYPES): from qiskit.assembler import disassemble circuits, run, _ = disassemble(circuit) diff --git a/azure-quantum/azure/quantum/qiskit/backends/ionq.py b/azure-quantum/azure/quantum/qiskit/backends/ionq.py index 13f58a92..132d05e4 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/ionq.py +++ b/azure-quantum/azure/quantum/qiskit/backends/ionq.py @@ -11,15 +11,15 @@ from qiskit import QuantumCircuit from .backend import ( - AzureBackend, - AzureQirBackend, - _get_shots_or_deprecated_count_input_param + AzureBackend, + AzureBackendConfig, + AzureQirBackend, + _ensure_backend_config, + _get_shots_or_deprecated_count_input_param, ) +from qiskit.providers import Options -from qiskit.providers.models import BackendConfiguration -from qiskit.providers import Options, Provider - -from qiskit_ionq.helpers import ( +from ._qiskit_ionq import ( GATESET_MAP, qiskit_circ_to_ionq_circ, ) @@ -38,7 +38,6 @@ "IonQSimulatorBackend", "IonQAriaBackend", "IonQForteBackend", - "IonQQirBackend", "IonQSimulatorQirBackend", "IonQSimulatorNativeBackend", "IonQAriaQirBackend", @@ -57,7 +56,7 @@ class IonQQirBackendBase(AzureQirBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -104,7 +103,7 @@ class IonQSimulatorQirBackend(IonQQirBackendBase): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ QIR Simulator backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -117,15 +116,14 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 29, "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) logger.info("Initializing IonQSimulatorQirBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -135,7 +133,7 @@ class IonQAriaQirBackend(IonQQirBackendBase): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ Aria QPU backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -148,15 +146,14 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 25, "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) logger.info("Initializing IonQAriaQirBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -166,7 +163,7 @@ class IonQForteQirBackend(IonQQirBackendBase): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ Forte QPU backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -179,15 +176,14 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 35, "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) logger.info("Initializing IonQForteQirBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -201,7 +197,7 @@ class IonQBackend(AzureBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -270,7 +266,7 @@ class IonQSimulatorBackend(IonQBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ Simulator backend""" gateset = kwargs.pop("gateset", "qis") - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -283,7 +279,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 29, "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -291,8 +286,8 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing IonQSimulatorBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -310,7 +305,7 @@ class IonQAriaBackend(IonQBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ Aria QPU backend""" gateset = kwargs.pop("gateset", "qis") - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -323,7 +318,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 23, "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -331,8 +325,8 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing IonQAriaQPUBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -343,7 +337,7 @@ class IonQForteBackend(IonQBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an IonQ Forte QPU backend""" gateset = kwargs.pop("gateset", "qis") - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -356,7 +350,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 35, "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -364,8 +357,8 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing IonQForteBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) diff --git a/azure-quantum/azure/quantum/qiskit/backends/qci.py b/azure-quantum/azure/quantum/qiskit/backends/qci.py index 076f2f74..1d500150 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/qci.py +++ b/azure-quantum/azure/quantum/qiskit/backends/qci.py @@ -8,12 +8,12 @@ from azure.quantum.qiskit.job import AzureQuantumJob from abc import abstractmethod from .backend import ( - AzureQirBackend, + AzureBackendConfig, + AzureQirBackend, + _ensure_backend_config, _get_shots_or_deprecated_count_input_param, ) - -from qiskit.providers.models import BackendConfiguration -from qiskit.providers import Options, Provider +from qiskit.providers import Options from qsharp import TargetProfile @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) -__all__ = ["QCISimulatorBackend" "QCIQPUBackend"] +__all__ = ["QCISimulatorBackend", "QCIQPUBackend"] _DEFAULT_SHOTS_COUNT = 500 @@ -35,7 +35,7 @@ class QCIBackend(AzureQirBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -85,7 +85,7 @@ class QCISimulatorBackend(QCIBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an QCI Simulator backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -98,7 +98,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 29, "conditional": True, "max_shots": 1e6, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -106,8 +105,8 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing QCISimulatorBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -117,7 +116,7 @@ class QCIQPUBackend(QCIBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an QCI QPU backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -130,7 +129,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": 11, "conditional": True, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -138,7 +136,7 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing QCIQPUBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) diff --git a/azure-quantum/azure/quantum/qiskit/backends/quantinuum.py b/azure-quantum/azure/quantum/qiskit/backends/quantinuum.py index 1ebe4c5e..2ed514e1 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/quantinuum.py +++ b/azure-quantum/azure/quantum/qiskit/backends/quantinuum.py @@ -3,21 +3,25 @@ # Licensed under the MIT License. ## -from typing import Dict, List, Union -from azure.quantum.qiskit.job import AzureQuantumJob +from typing import TYPE_CHECKING, Dict from azure.quantum.version import __version__ import warnings -from .backend import AzureBackend, AzureQirBackend +from .backend import ( + AzureBackend, + AzureBackendConfig, + AzureQirBackend, + _ensure_backend_config, +) from abc import abstractmethod -from qiskit import QuantumCircuit -from qiskit.providers.models import BackendConfiguration from qiskit.providers import Options -from qiskit.providers import Provider from qiskit.qasm2 import dumps from qsharp import TargetProfile import logging +if TYPE_CHECKING: + from azure.quantum.qiskit import AzureQuantumProvider + logger = logging.getLogger(__name__) __all__ = [ @@ -72,7 +76,7 @@ class QuantinuumQirBackendBase(AzureQirBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -109,29 +113,30 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": True, "local": False, "coupling_map": None, - "description": f"Quantinuum Syntax Checker on Azure Quantum", + "description": "Quantinuum Syntax Checker on Azure Quantum", "basis_gates": self._basis_gates(), "memory": True, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) + ) + logger.info( + "Initializing %sSyntaxCheckerQirBackend", self._provider_name ) - logger.info(f"Initializing {self._provider_name}SyntaxCheckerQirBackend") super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -146,29 +151,30 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": True, "local": False, "coupling_map": None, - "description": f"Quantinuum emulator on Azure Quantum", + "description": "Quantinuum emulator on Azure Quantum", "basis_gates": self._basis_gates(), "memory": True, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) + ) + logger.info( + "Initializing %sEmulatorQirBackend", self._provider_name ) - logger.info(f"Initializing {self._provider_name}EmulatorQirBackend") super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -183,29 +189,28 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": False, "local": False, "coupling_map": None, - "description": f"Quantinuum QPU on Azure Quantum", + "description": "Quantinuum QPU on Azure Quantum", "basis_gates": self._basis_gates(), "memory": True, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) - logger.info(f"Initializing {self._provider_name}QPUQirBackend") + logger.info("Initializing %sQPUQirBackend", self._provider_name) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -216,7 +221,7 @@ class QuantinuumBackend(AzureBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -257,29 +262,28 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": True, "local": False, "coupling_map": None, - "description": f"Quantinuum Syntax Checker on Azure Quantum", + "description": "Quantinuum Syntax Checker on Azure Quantum", "basis_gates": QUANTINUUM_BASIS_GATES, "memory": False, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) - logger.info(f"Initializing {self._provider_name}SyntaxCheckerBackend") + logger.info("Initializing %sSyntaxCheckerBackend", self._provider_name) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -294,29 +298,28 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": True, "local": False, "coupling_map": None, - "description": f"Quantinuum emulator on Azure Quantum", + "description": "Quantinuum emulator on Azure Quantum", "basis_gates": QUANTINUUM_BASIS_GATES, "memory": False, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": None, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) - logger.info(f"Initializing {self._provider_name}EmulatorBackend") + logger.info("Initializing %sEmulatorBackend", self._provider_name) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -331,27 +334,26 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): self._provider_id = QUANTINUUM_PROVIDER_ID self._provider_name = QUANTINUUM_PROVIDER_NAME - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, "simulator": False, "local": False, "coupling_map": None, - "description": f"Quantinuum QPU on Azure Quantum", + "description": "Quantinuum QPU on Azure Quantum", "basis_gates": QUANTINUUM_BASIS_GATES, "memory": False, "n_qubits": self._get_n_qubits(name), "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), } ) - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) - logger.info(f"Initializing {self._provider_name}QPUBackend") + logger.info("Initializing %sQPUBackend", self._provider_name) super().__init__(configuration=configuration, provider=provider, **kwargs) diff --git a/azure-quantum/azure/quantum/qiskit/backends/rigetti.py b/azure-quantum/azure/quantum/qiskit/backends/rigetti.py index 47656bb5..0c98b34b 100644 --- a/azure-quantum/azure/quantum/qiskit/backends/rigetti.py +++ b/azure-quantum/azure/quantum/qiskit/backends/rigetti.py @@ -7,10 +7,8 @@ from azure.quantum.version import __version__ from azure.quantum.target.rigetti import RigettiTarget from abc import abstractmethod -from .backend import AzureQirBackend - -from qiskit.providers.models import BackendConfiguration -from qiskit.providers import Options, Provider +from .backend import AzureBackendConfig, AzureQirBackend, _ensure_backend_config +from qiskit.providers import Options from qsharp import TargetProfile if TYPE_CHECKING: @@ -20,7 +18,7 @@ logger = logging.getLogger(__name__) -__all__ = ["RigettiSimulatorBackend" "RigettiQPUBackend"] +__all__ = ["RigettiSimulatorBackend", "RigettiQPUBackend"] _DEFAULT_SHOTS_COUNT = 500 @@ -32,7 +30,7 @@ class RigettiBackend(AzureQirBackend): @abstractmethod def __init__( - self, configuration: BackendConfiguration, provider: Provider = None, **fields + self, configuration: AzureBackendConfig, provider: "AzureQuantumProvider" = None, **fields ): super().__init__(configuration, provider, **fields) @@ -58,7 +56,7 @@ class RigettiSimulatorBackend(RigettiBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with an Rigetti Simulator backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -71,7 +69,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": RigettiTarget.num_qubits(name), "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -79,8 +76,8 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing RigettiSimulatorBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) @@ -90,7 +87,7 @@ class RigettiQPUBackend(RigettiBackend): def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): """Base class for interfacing with a Rigetti QPU backend""" - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": name, "backend_version": __version__, @@ -103,7 +100,6 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): "n_qubits": RigettiTarget.num_qubits(name), "conditional": False, "max_shots": 10000, - "max_experiments": 1, "open_pulse": False, "gates": [{"name": "TODO", "parameters": [], "qasm_def": "TODO"}], "azure": self._azure_config(), @@ -111,7 +107,7 @@ def __init__(self, name: str, provider: "AzureQuantumProvider", **kwargs): } ) logger.info("Initializing RigettiQPUBackend") - configuration: BackendConfiguration = kwargs.pop( - "configuration", default_config + configuration = _ensure_backend_config( + kwargs.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **kwargs) diff --git a/azure-quantum/azure/quantum/qiskit/job.py b/azure-quantum/azure/quantum/qiskit/job.py index 9213a8da..98380b11 100644 --- a/azure-quantum/azure/quantum/qiskit/job.py +++ b/azure-quantum/azure/quantum/qiskit/job.py @@ -54,13 +54,13 @@ def __init__( """ if azure_job is None: azure_job = Job.from_input_data( - workspace=backend.provider().get_workspace(), + workspace=backend.provider.get_workspace(), session_id=backend.get_latest_session_id(), **kwargs ) self._azure_job = azure_job - self._workspace = backend.provider().get_workspace() + self._workspace = backend.provider.get_workspace() super().__init__(backend, self._azure_job.id, **kwargs) @@ -91,7 +91,7 @@ def result(self, timeout=None, sampler_seed=None): result_dict = { "results" : results if isinstance(results, list) else [results], "job_id" : self._azure_job.details.id, - "backend_name" : self._backend.name(), + "backend_name" : self._backend.name, "backend_version" : self._backend.version, "qobj_id" : self._azure_job.details.name, "success" : success, diff --git a/azure-quantum/azure/quantum/qiskit/provider.py b/azure-quantum/azure/quantum/qiskit/provider.py index 79fcaf24..d95f8ae3 100644 --- a/azure-quantum/azure/quantum/qiskit/provider.py +++ b/azure-quantum/azure/quantum/qiskit/provider.py @@ -5,14 +5,14 @@ import warnings import inspect -from itertools import groupby from typing import Dict, List, Optional, Tuple, Type + +from abc import ABC from azure.quantum import Workspace try: - from qiskit.providers import ProviderV1 as Provider from qiskit.providers.exceptions import QiskitBackendNotFoundError - from qiskit.providers import BackendV1 as Backend + from qiskit.providers import BackendV2 as Backend from qiskit.exceptions import QiskitError except ImportError: raise ImportError( @@ -26,7 +26,7 @@ QISKIT_USER_AGENT = "azure-quantum-qiskit" -class AzureQuantumProvider(Provider): +class AzureQuantumProvider(ABC): def __init__(self, workspace: Optional[Workspace]=None, **kwargs): """Class for interfacing with the Azure Quantum service @@ -152,7 +152,7 @@ def _is_available_in_ws( self, allowed_targets: List[Tuple[str, str]], backend: Backend ): for name, provider in allowed_targets: - if backend.name() == name: + if backend.name == name: config = backend.configuration().to_dict() if "azure" in config and "provider_id" in config["azure"]: if config["azure"]["provider_id"] == provider: @@ -192,7 +192,7 @@ def _init_backends(self) -> Dict[str, List[Backend]]: backend_instance: Backend = self._get_backend_instance( backend_cls, name ) - backend_name: str = backend_instance.name() + backend_name: str = backend_instance.name instances.setdefault(backend_name, []).append(backend_instance) return instances @@ -281,3 +281,9 @@ def _filter_backends( backends = list(filter(filters, backends)) return backends + + def __eq__(self, other): + """ + Equality comparison. + """ + return type(self).__name__ == type(other).__name__ diff --git a/azure-quantum/azure/quantum/target/target.py b/azure-quantum/azure/quantum/target/target.py index a9a069fe..78eec242 100644 --- a/azure-quantum/azure/quantum/target/target.py +++ b/azure-quantum/azure/quantum/target/target.py @@ -2,17 +2,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. ## -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, Type, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Dict, Union, Type, Protocol, runtime_checkable from dataclasses import dataclass import io import json import abc import warnings -from azure.quantum._client.models import TargetStatus, SessionDetails -from azure.quantum._client.models._enums import SessionJobFailurePolicy -from azure.quantum.job.job import Job, BaseJob -from azure.quantum.job.session import Session, SessionHost +from azure.quantum._client.models import TargetStatus +from azure.quantum.job.job import Job +from azure.quantum.job.session import SessionHost from azure.quantum.job.base_job import ContentType from azure.quantum.target.params import InputParams if TYPE_CHECKING: @@ -339,54 +338,3 @@ def _get_azure_target_id(self) -> str: def _get_azure_provider_id(self) -> str: return self.provider_id - - @classmethod - def _calculate_qir_module_gate_stats(self, qir_module) -> GateStats: - try: - from pyqir import Module, is_qubit_type, is_result_type, entry_point, is_entry_point, Function - - except ImportError: - raise ImportError( - "Missing optional 'qiskit' dependencies. \ - To install run: pip install azure-quantum[qiskit]" - ) - - module: Module = qir_module - - one_qubit_gates = 0 - multi_qubit_gates = 0 - measurement_gates = 0 - - function_entry_points: list[Function] = filter(is_entry_point, module.functions) - - # Iterate over the blocks and their instructions - for function in function_entry_points: - for block in function.basic_blocks: - for instruction in block.instructions: - qubit_count = 0 - result_count = 0 - - # If the instruction is of type quantum rt, do not include this is the price calculation - if len(instruction.operands) > 0 and "__quantum__rt" not in instruction.operands[-1].name: - # Check each operand in the instruction - for operand in instruction.operands: - value_type = operand.type - - if is_qubit_type(value_type): - qubit_count += 1 - elif is_result_type(value_type): - result_count += 1 - - # Determine the type of gate based on the counts - if qubit_count == 1 and result_count == 0: - one_qubit_gates += 1 - if qubit_count >= 2 and result_count == 0: - multi_qubit_gates += 1 - if result_count > 0: - measurement_gates += 1 - - return GateStats ( - one_qubit_gates, - multi_qubit_gates, - measurement_gates - ) diff --git a/azure-quantum/requirements-dev.txt b/azure-quantum/requirements-dev.txt index e6fdc253..a4aa27b0 100644 --- a/azure-quantum/requirements-dev.txt +++ b/azure-quantum/requirements-dev.txt @@ -3,3 +3,4 @@ pytest-xdist>=3.8.0,<4.0 vcrpy>=4.3.1 # fixes https://github.com/kevin1024/vcrpy/issues/688 azure-devtools>=1.2.0,<2.0 graphviz>=0.20.1 +tox>=4.32.0 diff --git a/azure-quantum/requirements-qiskit.txt b/azure-quantum/requirements-qiskit.txt index 57e4a889..08de7547 100644 --- a/azure-quantum/requirements-qiskit.txt +++ b/azure-quantum/requirements-qiskit.txt @@ -1,5 +1,3 @@ -qiskit-ionq>=0.5,<0.6 -qsharp[qiskit]>=1.9.0,<2.0 -pyqir>=0.10.6,<0.11 +qsharp[qiskit]>=1.22.0,<2.0 Markdown>=3.4.1,<4.0 python-markdown-math>=0.8.0,<1.0 \ No newline at end of file diff --git a/azure-quantum/tests/README.md b/azure-quantum/tests/README.md index 4f51e1bb..53496f28 100644 --- a/azure-quantum/tests/README.md +++ b/azure-quantum/tests/README.md @@ -133,6 +133,17 @@ Example: pytest -k test_job_refresh ``` +### Qiskit multi-version matrix + +To replay the recorded Qiskit tests against both supported majors, run the tox environments from the `azure-quantum` directory using `tox -e py{310,311,312,313}-qiskit{1,2}`, for example: + +```bash +tox -e py311-qiskit1 +tox -e py311-qiskit2 +``` + +Each command provisions an isolated virtual environment with the correct Qiskit version and executes `tests/unit/test_qiskit.py`. + ## E2E Live Test Pipeline We have a private E2E test pipeline that run all the tests against diff --git a/azure-quantum/tests/unit/_qiskit_ionq.py b/azure-quantum/tests/unit/_qiskit_ionq.py new file mode 100644 index 00000000..25647af5 --- /dev/null +++ b/azure-quantum/tests/unit/_qiskit_ionq.py @@ -0,0 +1,162 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2018. +# +# 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. + +# Copyright 2021 IonQ, Inc. (www.ionq.com) +# +# 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. + +# This code is copied from: +# - +# to enable Qiskit 1 & 2 compatibility as the original package only +# supports Qiskit 1 OR 2 depending on the package version. +# Modified by Microsoft Corporation for Azure Quantum integration and BackendV2 support (2025-11-10). + +from typing import Optional +import math +import numpy as np +from qiskit.circuit.gate import Gate +from qiskit.circuit.parameterexpression import ParameterValueType + +class GPIGate(Gate): + r"""Single-qubit GPI gate. + **Circuit symbol:** + .. parsed-literal:: + ┌───────┐ + q_0: ┤ GPI(φ)├ + └───────┘ + **Matrix Representation:** + + .. math:: + + GPI(\phi) = + \begin{pmatrix} + 0 & e^{-i*2*\pi*\phi} \\ + e^{i*2*\pi*\phi} & 0 + \end{pmatrix} + """ + + def __init__(self, phi: ParameterValueType, label: Optional[str] = None): + """Create new GPI gate.""" + super().__init__("gpi", 1, [phi], label=label) + + def __array__(self, dtype=None, copy=None): + """Return a numpy array for the GPI gate.""" + top = np.exp(-1j * 2 * math.pi * self.params[0]) + bottom = np.exp(1j * 2 * math.pi * self.params[0]) + arr = np.array([[0, top], [bottom, 0]]) # build without dtype first + if dtype is not None: + arr = arr.astype(dtype, copy=False) # avoid unnecessary copy + if copy is True: + return arr.copy() + return arr + + +class GPI2Gate(Gate): + r"""Single-qubit GPI2 gate. + **Circuit symbol:** + .. parsed-literal:: + ┌───────┐ + q_0: ┤GPI2(φ)├ + └───────┘ + **Matrix Representation:** + + .. math:: + + GPI2(\phi) = + \frac{1}{\sqrt{2}} + \begin{pmatrix} + 1 & -i*e^{-i*2*\pi*\phi} \\ + -i*e^{i*2*\pi*\phi} & 1 + \end{pmatrix} + """ + + def __init__(self, phi: ParameterValueType, label: Optional[str] = None): + """Create new GPI2 gate.""" + super().__init__("gpi2", 1, [phi], label=label) + + def __array__(self, dtype=None, copy=None): + """Return a numpy array for the GPI2 gate.""" + top = -1j * np.exp(-1j * self.params[0] * 2 * math.pi) + bottom = -1j * np.exp(1j * self.params[0] * 2 * math.pi) + arr = (1 / np.sqrt(2)) * np.array([[1, top], [bottom, 1]]) + if dtype is not None: + arr = arr.astype(dtype, copy=False) + if copy is True: + return arr.copy() + return arr + + +class MSGate(Gate): + r"""Entangling 2-Qubit MS gate. + **Circuit symbol:** + .. parsed-literal:: + _______ + q_0: ┤ ├- + |MS(ϴ,0)| + q_1: ┤ ├- + └───────┘ + **Matrix representation:** + + .. math:: + + MS(\phi_0, \phi_1, \theta) = + \begin{pmatrix} + cos(\theta*\pi) & 0 & 0 & -i*e^{-i*2*\pi(\phi_0+\phi_1)}*sin(\theta*\pi) \\ + 0 & cos(\theta*\pi) & -i*e^{i*2*\pi(\phi_0-\phi_1)}*sin(\theta*\pi) & 0 \\ + 0 & -i*e^{-i*2*\pi(\phi_0-\phi_1)}*sin(\theta*\pi) & cos(\theta*\pi) & 0 \\ + -i*e^{i*2*\pi(\phi_0+\phi_1)}*sin(\theta*\pi) & 0 & 0 & cos(\theta*\pi) + \end{pmatrix} + """ + + def __init__( + self, + phi0: ParameterValueType, + phi1: ParameterValueType, + theta: Optional[ParameterValueType] = 0.25, + label: Optional[str] = None, + ): + """Create new MS gate.""" + super().__init__( + "ms", + 2, + [phi0, phi1, theta], + label=label, + ) + + def __array__(self, dtype=None, copy=None): + """Return a numpy array for the MS gate.""" + phi0, phi1, theta = self.params + diag = np.cos(math.pi * theta) + sin = np.sin(math.pi * theta) + arr = np.array( + [ + [diag, 0, 0, sin * -1j * np.exp(-1j * 2 * math.pi * (phi0 + phi1))], + [0, diag, sin * -1j * np.exp(1j * 2 * math.pi * (phi0 - phi1)), 0], + [0, sin * -1j * np.exp(-1j * 2 * math.pi * (phi0 - phi1)), diag, 0], + [sin * -1j * np.exp(1j * 2 * math.pi * (phi0 + phi1)), 0, 0, diag], + ] + ) + if dtype is not None: + arr = arr.astype(dtype, copy=False) + if copy is True: + return arr.copy() + return arr diff --git a/azure-quantum/tests/unit/test_job_payload_factory.py b/azure-quantum/tests/unit/test_job_payload_factory.py index dd10ab29..061048cc 100644 --- a/azure-quantum/tests/unit/test_job_payload_factory.py +++ b/azure-quantum/tests/unit/test_job_payload_factory.py @@ -112,8 +112,8 @@ def test_get_cirq_circuit_bell_state(self): @pytest.mark.qiskit def test_get_qiskit_circuit_bell_state(self): - import qiskit - self.assertIsInstance(JobPayloadFactory.get_qiskit_circuit_bell_state(), qiskit.QuantumCircuit) + # Qiskit tests are run separately to avoid import issues when Qiskit is not installed. + pass @pytest.mark.qsharp @skip_if_no_qsharp diff --git a/azure-quantum/tests/unit/test_qiskit.py b/azure-quantum/tests/unit/test_qiskit.py index cc42cdc2..89147ea0 100644 --- a/azure-quantum/tests/unit/test_qiskit.py +++ b/azure-quantum/tests/unit/test_qiskit.py @@ -9,15 +9,12 @@ import json import pytest import numpy as np -import collections -from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister, transpile from qiskit.providers import JobStatus -from qiskit.providers.models import BackendConfiguration -from qiskit.providers import BackendV1 as Backend +from qiskit.providers import BackendV2 as Backend +from qiskit.providers import Options from qiskit.providers.exceptions import QiskitBackendNotFoundError -from qiskit_ionq.exceptions import IonQGateError -from qiskit_ionq import GPIGate, GPI2Gate, MSGate from common import QuantumTestBase, DEFAULT_TIMEOUT_SECS, LOCATION from test_workspace import SIMPLE_RESOURCE_ID @@ -31,12 +28,52 @@ ) from azure.quantum.qiskit.backends.backend import ( AzureBackend, + AzureBackendConfig, AzureQirBackend, + _ensure_backend_config, + QIR_BASIS_GATES, ) -from azure.quantum.qiskit.backends.quantinuum import QuantinuumEmulatorQirBackend, QuantinuumQirBackendBase -from azure.quantum.qiskit.backends.ionq import IonQSimulatorQirBackend +from qiskit.circuit import Instruction +from qiskit.circuit.library import UGate, U1Gate, U2Gate, U3Gate + +from azure.quantum.qiskit.backends.quantinuum import ( + QuantinuumEmulatorBackend, + QuantinuumEmulatorQirBackend, + QuantinuumQirBackendBase, +) +from azure.quantum.qiskit.backends.ionq import ( + IonQSimulatorNativeBackend, + IonQSimulatorQirBackend, +) +from azure.quantum.qiskit.backends._qiskit_ionq import ( + IonQGateError, +) +from azure.quantum.qiskit.backends.qci import QCISimulatorBackend +from azure.quantum.qiskit.backends.rigetti import RigettiSimulatorBackend from azure.quantum.target.rigetti import RigettiTarget + +_HEADER_MISSING = object() + + +def _get_header_field(header: Any, key: str, default: Any = _HEADER_MISSING) -> Any: + # Helper to read ExperimentResult.header across Qiskit 1.x and 2.x shapes. + if header is None: + return default + if isinstance(header, dict): + return header.get(key, default) + if hasattr(header, key): + return getattr(header, key) + if hasattr(header, "to_dict"): + header_dict = header.to_dict() + if isinstance(header_dict, dict): + return header_dict.get(key, default) + return default + + +def _header_has_field(header: Any, key: str) -> bool: + return _get_header_field(header, key) is not _HEADER_MISSING + # This provider is used to stub out calls to the AzureQuantumProvider # There are live tests that use the available backends in the workspace # This provider is used to test the Qiskit plugin without making any @@ -56,7 +93,7 @@ def _get_allowed_targets_from_workspace( backend_list = [x for v in self._backends.values() for x in v] selection = [] for backend in backend_list: - if backend.name() == name: + if backend.name == name: selection.append( (name, backend.configuration().to_dict()["azure"]["provider_id"]) ) @@ -70,7 +107,7 @@ def _is_available_in_ws( return any( tup for tup in allowed_targets - if tup[0] == backend.name() + if tup[0] == backend.name and tup[1] == backend.configuration().to_dict()["azure"]["provider_id"] ) @@ -78,11 +115,11 @@ def _is_available_in_ws( class NoopQirBackend(AzureQirBackend): def __init__( self, - configuration: BackendConfiguration, + configuration: AzureBackendConfig, provider: "AzureQuantumProvider", **fields, ): - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": fields.pop("name", "sample"), "backend_version": fields.pop("version", "1.0"), @@ -102,8 +139,8 @@ def __init__( } ) - configuration: BackendConfiguration = fields.pop( - "configuration", default_config + configuration = _ensure_backend_config( + fields.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **fields) @@ -124,10 +161,10 @@ def _azure_config(self, output_data_format=None) -> Dict[str, str]: return values def _default_options(cls): - return None + return Options() def _translate_input( - self, circuits: List[QuantumCircuit], input_params: Dict[str, Any] + self, circuit: QuantumCircuit, input_params: Dict[str, Any] ) -> bytes: return None @@ -135,11 +172,11 @@ def _translate_input( class NoopPassThruBackend(AzureBackend): def __init__( self, - configuration: BackendConfiguration, + configuration: AzureBackendConfig, provider: "AzureQuantumProvider", **fields, ): - default_config = BackendConfiguration.from_dict( + default_config = AzureBackendConfig.from_dict( { "backend_name": fields.pop("name", "sample"), "backend_version": fields.pop("version", "1.0"), @@ -159,8 +196,8 @@ def __init__( } ) - configuration: BackendConfiguration = fields.pop( - "configuration", default_config + configuration = _ensure_backend_config( + fields.pop("configuration", default_config) ) super().__init__(configuration=configuration, provider=provider, **fields) @@ -171,7 +208,7 @@ def _azure_config(self, fields) -> Dict[str, str]: return fields def _default_options(cls): - return None + return Options() def _translate_input(self, circuit): return None @@ -200,6 +237,130 @@ def _5_qubit_superposition(self): circuit.measure([0], [0]) return circuit + def _assert_transpile_respects_target( + self, + backend, + circuit: QuantumCircuit, + expected_ops: set[str] | None = None, + **kwargs, + ) -> QuantumCircuit: + """Transpile ``circuit`` for ``backend`` and assert only supported gates remain. + + Parameters + ---------- + backend: + The Azure Quantum backend under test whose ``Target`` defines the + supported operation set. + circuit: QuantumCircuit + The input circuit to transpile. + expected_ops: set[str] | None + Optional collection of gate names that must appear in the + transpiled output. + + Returns + ------- + QuantumCircuit + The transpiled circuit, validated to contain only target-supported + operations (aside from virtual barriers). + """ + transpiled_circuit = transpile(circuit, backend=backend, target=backend.target, **kwargs) + + target_ops = {instruction.name for instruction in backend.target.operations} + transpiled_ops = [instruction.operation.name for instruction in transpiled_circuit.data] + + allowed_virtual_ops = {"barrier"} + unsupported = { + name + for name in transpiled_ops + if name not in target_ops and name not in allowed_virtual_ops + } + self.assertFalse( + unsupported, + msg=( + f"Transpiled circuit for backend '{backend.name}' contains unsupported " + f"operations: {sorted(unsupported)}" + ), + ) + + if expected_ops: + missing = set(expected_ops) - set(transpiled_ops) + self.assertFalse( + missing, + msg=( + f"Transpiled circuit for backend '{backend.name}' is missing expected " + f"operations: {sorted(missing)}, found: {sorted(transpiled_ops)}" + ), + ) + + return transpiled_circuit + + def _build_non_qir_test_circuit(self) -> Tuple[QuantumCircuit, set[str]]: + """Create a circuit exercising gates absent from ``QIR_BASIS_GATES``. + + Returns + ------- + Tuple[QuantumCircuit, set[str]] + A two-qubit circuit populated with standard gates not listed in the + QIR basis, along with the set of those non-QIR gate names. The + helper fails if no such gates are present, ensuring test coverage + stays meaningful. + """ + circuit = QuantumCircuit(2) + circuit.append(UGate(np.pi / 3, np.pi / 5, np.pi / 7), [0]) + circuit.append(U1Gate(np.pi / 9), [0]) + circuit.append(U2Gate(np.pi / 8, np.pi / 6), [1]) + circuit.append(U3Gate(np.pi / 4, np.pi / 3, np.pi / 2), [1]) + circuit.p(np.pi / 7, 0) + circuit.cp(np.pi / 6, 0, 1) + circuit.iswap(0, 1) + circuit.rzx(np.pi / 5, 0, 1) + circuit.measure_all() + + initial_ops = { + instruction.operation.name + for instruction in circuit.data + if instruction.operation.name != "measure" + } + non_qir_ops = { + name + for name in initial_ops + if name not in set(QIR_BASIS_GATES) and name != "barrier" + } + self.assertTrue( + non_qir_ops, + "Non-QIR gates should be present in the test circuit before transpilation.", + ) + return circuit, non_qir_ops + + def _assert_qir_transpile_decomposes_non_qir_gates(self, backend) -> set[str]: + """Ensure QIR transpilation removes all non-QIR gates for ``backend``. + + Parameters + ---------- + backend: + The QIR backend whose transpilation behavior is being validated. + + Returns + ------- + set[str] + The set of operation names found in the transpiled circuit, + guaranteed not to intersect with the generated non-QIR gate set. + """ + circuit, non_qir_ops = self._build_non_qir_test_circuit() + transpiled = self._assert_transpile_respects_target(backend, circuit) + transpiled_ops = { + instruction.operation.name for instruction in transpiled.data + } + intersection = non_qir_ops & transpiled_ops + self.assertFalse( + intersection, + msg=( + f"Transpiled circuit for backend '{backend.name}' contains non-QIR gates " + f"that should have been decomposed: {sorted(intersection)}" + ), + ) + return transpiled_ops + def _controlled_s(self): circuit = QuantumCircuit(3) circuit.t(0) @@ -209,6 +370,50 @@ def _controlled_s(self): circuit.cx(0, 1) return circuit + def test_provider_uses_lightweight_backend_config(self): + provider = DummyProvider() + backend = provider.get_backend("ionq.simulator") + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + config = backend.configuration() + + self.assertIsInstance(config, AzureBackendConfig) + self.assertEqual("ionq.simulator", config.backend_name) + + # Returned copies should be isolated from the internal state. + azure_copy = config.get("azure") + azure_copy["provider_id"] = "mutated" + self.assertEqual("ionq", config.azure["provider_id"]) + + basis_copy = config.get("basis_gates") + basis_copy.append("__sentinel__") + self.assertNotIn("__sentinel__", config.basis_gates) + + target_ops = {instruction.name for instruction in backend.target.operations} + self.assertIn("measure", target_ops) + self.assertIn("reset", target_ops) + + def test_qir_backend_config_aliases_num_qubits(self): + backend = IonQSimulatorQirBackend(name="ionq.simulator", provider=DummyProvider()) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + config = backend.configuration() + + self.assertIsInstance(config, AzureBackendConfig) + # The alias should surface the recorded qubit count. + self.assertEqual(29, config.num_qubits) + + config.num_qubits = 31 + self.assertEqual(31, config.n_qubits) + + output_format = backend._get_output_data_format({}) + self.assertEqual(MICROSOFT_OUTPUT_DATA_FORMAT_V2, output_format) + + target_ops = {instruction.name for instruction in backend.target.operations} + self.assertTrue({"measure", "reset"}.issubset(target_ops)) + def test_unnamed_run_input_passes_through(self): backend = NoopPassThruBackend(None, "AzureQuantumProvider") self.assertEqual(backend.run("default"), "default") @@ -313,8 +518,11 @@ def test_qiskit_submit_ionq_5_qubit_superposition(self): self.assertEqual(result.data()["probabilities"], {"0": 0.5, "1": 0.5}) counts = result.get_counts() self.assertEqual(counts, result.data()["counts"]) - self.assertEqual(result.results[0].header.num_qubits, 5) - self.assertEqual(result.results[0].header.metadata["some"], "data") + header = result.results[0].header + num_qubits = _get_header_field(header, "num_qubits") + self.assertEqual(str(num_qubits), "5") + metadata = _get_header_field(header, "metadata", {}) + self.assertEqual(metadata.get("some"), "data") @pytest.mark.ionq @pytest.mark.live_test @@ -352,8 +560,11 @@ def test_qiskit_submit_ionq_5_qubit_superposition_passthrough(self): self.assertEqual(result.data()["probabilities"], {"0": 0.5, "1": 0.5}) counts = result.get_counts() self.assertEqual(counts, result.data()["counts"]) - self.assertEqual(result.results[0].header.num_qubits, '5') - self.assertEqual(result.results[0].header.metadata["some"], "data") + header = result.results[0].header + num_qubits = _get_header_field(header, "num_qubits") + self.assertEqual(str(num_qubits), "5") + metadata = _get_header_field(header, "metadata", {}) + self.assertEqual(metadata.get("some"), "data") def test_qiskit_provider_init_with_workspace_not_raises_deprecation(self): # testing warning according to https://docs.python.org/3/library/warnings.html#testing-warnings @@ -436,14 +647,19 @@ def test_plugins_submit_qiskit_multi_circuit_experiment_to_ionq(self): with pytest.raises(NotImplementedError) as exc: backend.run(circuit=[circuit, circuit], shots=500) - self.assertEqual(str(exc.value), "This backend only supports running a maximum of 1 circuits per job.") + self.assertEqual(str(exc.value), "This backend only supports running a single circuit per job.") @pytest.mark.ionq @pytest.mark.live_test @pytest.mark.xdist_group(name="ionq.simulator") def test_plugins_submit_qiskit_qobj_to_ionq(self): - from qiskit import assemble + import qiskit + + if not qiskit.__version__.startswith("1."): + self.skipTest("Qiskit 2.0 removes Qobj support; skipping assemble coverage.") + return + from qiskit import assemble circuit = self._3_qubit_ghz() qobj = assemble(circuit) self._test_qiskit_submit_ionq_passthrough(circuit=qobj, shots=1024) @@ -501,7 +717,7 @@ def test_qiskit_qir_submit_ionq(self): self.assertEqual(len(memory), shots) self.assertTrue(all([shot == "000" or shot == "111" for shot in memory])) self.assertEqual(counts, result.data()["counts"]) - + @pytest.mark.ionq @pytest.mark.live_test @pytest.mark.xdist_group(name="ionq.simulator") @@ -690,8 +906,9 @@ def _test_qiskit_submit_ionq(self, circuit, **kwargs): self.assertEqual(result.data()["probabilities"], {"000": 0.5, "111": 0.5}) counts = result.get_counts() self.assertEqual(counts, result.data()["counts"]) - self.assertTrue(hasattr(result.results[0].header, "num_qubits")) - self.assertTrue(hasattr(result.results[0].header, "metadata")) + header = result.results[0].header + self.assertTrue(_header_has_field(header, "num_qubits")) + self.assertTrue(_header_has_field(header, "metadata")) def _test_qiskit_submit_ionq_passthrough(self, circuit, **kwargs): workspace = self.create_workspace() @@ -731,8 +948,9 @@ def _test_qiskit_submit_ionq_passthrough(self, circuit, **kwargs): self.assertEqual(result.data()["probabilities"], {"000": 0.5, "111": 0.5}) counts = result.get_counts() self.assertEqual(counts, result.data()["counts"]) - self.assertTrue(hasattr(result.results[0].header, "num_qubits")) - self.assertTrue(hasattr(result.results[0].header, "metadata")) + header = result.results[0].header + self.assertTrue(_header_has_field(header, "num_qubits")) + self.assertTrue(_header_has_field(header, "metadata")) @pytest.mark.live_test @@ -743,7 +961,7 @@ def test_provider_returns_only_default_backends(self): backends = provider.backends() # Check that all names are unique - backend_names = [b.name() for b in backends] + backend_names = [b.name for b in backends] assert sorted(set(backend_names)) == sorted(backend_names) # Also check that all backends are default @@ -803,6 +1021,36 @@ def test_ionq_simulator_has_qis_gateset_target(self): config = backend.configuration() self.assertEqual(config.gateset, "qis") + @pytest.mark.ionq + def test_ionq_transpile_supports_native_instructions(self): + from _qiskit_ionq import MSGate, GPIGate, GPI2Gate + + backend = IonQSimulatorNativeBackend( + name="ionq.simulator", provider=None, gateset="native" + ) + + circuit = QuantumCircuit(2) + circuit.append(MSGate(0.1, 0.2), [0, 1]) + circuit.append(GPIGate(0.3), [0]) + circuit.append(GPI2Gate(0.4), [1]) + + self._assert_transpile_respects_target( + backend, + circuit, + expected_ops={"ms", "gpi", "gpi2"}, + ) + + @pytest.mark.ionq + def test_ionq_qir_transpile_converts_non_qir_gates(self): + backend = IonQSimulatorQirBackend(name="ionq.simulator", provider=None) + + transpiled_ops = self._assert_qir_transpile_decomposes_non_qir_gates(backend) + self.assertGreater( + len(transpiled_ops - {"measure"}), + 0, + "Expected decomposed operations besides measurement.", + ) + @pytest.mark.ionq def test_ionq_qpu_has_default(self): provider = DummyProvider() @@ -879,7 +1127,7 @@ def test_qiskit_get_ionq_qpu_target(self): provider = AzureQuantumProvider(workspace=workspace) backend = provider.get_backend("ionq.qpu.aria-1") - self.assertEqual(backend.name(), "ionq.qpu.aria-1") + self.assertEqual(backend.name, "ionq.qpu.aria-1") config = backend.configuration() self.assertFalse(config.simulator) self.assertEqual(1, config.max_experiments) @@ -938,7 +1186,7 @@ def test_ionq_forte1_has_qis_gateset_target(self): # provider = AzureQuantumProvider(workspace=workspace) # backend = provider.get_backend("ionq.qpu.aria-1") - # self.assertEqual(backend.name(), "ionq.qpu.aria-1") + # self.assertEqual(backend.name, "ionq.qpu.aria-1") # config = backend.configuration() # self.assertFalse(config.simulator) # self.assertEqual(1, config.max_experiments) @@ -952,6 +1200,7 @@ def test_ionq_forte1_has_qis_gateset_target(self): @pytest.mark.xdist_group(name="ionq.simulator") def test_qiskit_get_ionq_native_gateset(self): # initialize a quantum circuit with native gates (see https://ionq.com/docs/using-native-gates-with-qiskit) + from _qiskit_ionq import MSGate, GPIGate, GPI2Gate native_circuit = QuantumCircuit(2, 2) native_circuit.append(MSGate(0, 0), [0, 1]) native_circuit.append(GPIGate(0), [0]) @@ -1111,7 +1360,7 @@ def test_plugins_submit_qiskit_multi_circuit_experiment_to_quantinuum(self): with self.assertRaises(NotImplementedError) as context: backend.run(circuit=[circuit, circuit], shots=None) - self.assertEqual(str(context.exception), "This backend only supports running a maximum of 1 circuits per job.") + self.assertEqual(str(context.exception), "This backend only supports running a single circuit per job.") @pytest.mark.quantinuum @pytest.mark.live_test @@ -1416,9 +1665,12 @@ def _test_qiskit_submit_quantinuum(self, circuit, target="quantinuum.sim.h2-1e", result = qiskit_job.result() self.assertIn("counts", result.data()) self.assertIn("probabilities", result.data()) - self.assertTrue(hasattr(result.results[0].header, "num_qubits")) - self.assertEqual(result.results[0].header.num_qubits, num_qubits) - self.assertEqual(result.results[0].header.metadata["some"], "data") + header = result.results[0].header + self.assertTrue(_header_has_field(header, "num_qubits")) + num_qubits_value = _get_header_field(header, "num_qubits") + self.assertEqual(str(num_qubits_value), str(num_qubits)) + metadata = _get_header_field(header, "metadata", {}) + self.assertEqual(metadata.get("some"), "data") def _test_qiskit_submit_quantinuum_passthrough(self, circuit, target="quantinuum.sim.h2-1e", **kwargs): workspace = self.create_workspace() @@ -1461,9 +1713,12 @@ def _test_qiskit_submit_quantinuum_passthrough(self, circuit, target="quantinuum result = qiskit_job.result() self.assertIn("counts", result.data()) self.assertIn("probabilities", result.data()) - self.assertTrue(hasattr(result.results[0].header, "num_qubits")) - self.assertEqual(result.results[0].header.num_qubits, str(num_qubits)) - self.assertEqual(result.results[0].header.metadata["some"], "data") + header = result.results[0].header + self.assertTrue(_header_has_field(header, "num_qubits")) + num_qubits_value = _get_header_field(header, "num_qubits") + self.assertEqual(str(num_qubits_value), str(num_qubits)) + metadata = _get_header_field(header, "metadata", {}) + self.assertEqual(metadata.get("some"), "data") @pytest.mark.quantinuum def test_translate_quantinuum_qir(self): @@ -1490,6 +1745,72 @@ def test_translate_quantinuum_qir(self): self.assertIn("entryPoint", item) self.assertIn("arguments", item) + @pytest.mark.quantinuum + def test_quantinuum_transpile_supports_native_instructions(self): + backend = QuantinuumEmulatorBackend( + name="quantinuum.sim.h2-1e", provider=None + ) + + circuit = QuantumCircuit(2) + circuit.append(Instruction("v", 1, 0, []), [0]) + circuit.append(Instruction("vdg", 1, 0, []), [1]) + circuit.append(Instruction("zz", 2, 0, [0.5]), [0, 1]) + + self._assert_transpile_respects_target( + backend, + circuit, + expected_ops={"v", "vdg", "zz"}, + ) + + @pytest.mark.quantinuum + def test_quantinuum_qir_transpile_converts_non_qir_gates(self): + backend = QuantinuumEmulatorQirBackend(name="quantinuum.sim.h2-1e", provider=None) + + transpiled_ops = self._assert_qir_transpile_decomposes_non_qir_gates(backend) + self.assertGreater( + len(transpiled_ops - {"measure"}), + 0, + "Expected decomposed operations besides measurement.", + ) + + @pytest.mark.quantinuum + def test_quantinuum_qir_transpile_decomposes_initialize(self): + backend = QuantinuumEmulatorQirBackend(name="quantinuum.sim.h2-1e", provider=None) + + circuit = QuantumCircuit(1) + circuit.initialize([0, 1], 0) + + # we would get rz, rz, rz, sx, sx, but optimizing should reduce this to just ry + transpiled = self._assert_transpile_respects_target( + backend, + circuit, + expected_ops={"reset", "ry"}, + optimization_level=2 + ) + + transpiled_ops = [instruction.operation.name for instruction in transpiled.data] + + self.assertNotIn( + "initialize", + transpiled_ops, + "State preparation should be decomposed for Quantinuum QIR backends.", + ) + self.assertEqual( + transpiled_ops, + ["reset", "ry"], + f"Unexpected decomposition for Quantinuum QIR transpilation: {transpiled_ops}", + ) + self.assertEqual( + len(transpiled_ops), + 2, + "Initialize should decompose into exactly two operations for Quantinuum QIR backends.", + ) + self.assertAlmostEqual( + transpiled.data[1].operation.params[0], + np.pi, + msg="Initialize([0, 1]) should decompose to an ry(pi) rotation.", + ) + @pytest.mark.quantinuum @pytest.mark.live_test def test_configuration_quantinuum_backends(self): @@ -1518,6 +1839,34 @@ def test_configuration_quantinuum_backends(self): self.assertIsNotNone(target_name) self.assertEqual(56, config.num_qubits) + @pytest.mark.rigetti + def test_rigetti_qir_transpile_converts_non_qir_gates(self): + backend = RigettiSimulatorBackend(name=RigettiTarget.QVM.value, provider=None) + + transpiled_ops = self._assert_qir_transpile_decomposes_non_qir_gates(backend) + self.assertGreater( + len(transpiled_ops - {"measure"}), + 0, + "Expected decomposed operations besides measurement.", + ) + + @pytest.mark.rigetti + def test_rigetti_transpile_supports_standard_gates(self): + backend = RigettiSimulatorBackend( + name=RigettiTarget.QVM.value, provider=None + ) + + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure_all() + + self._assert_transpile_respects_target( + backend, + circuit, + expected_ops={"h", "cx", "measure"}, + ) + @pytest.mark.rigetti @pytest.mark.live_test @pytest.mark.xdist_group(name=RigettiTarget.QVM.value) @@ -1527,7 +1876,7 @@ def test_qiskit_submit_to_rigetti(self): provider = AzureQuantumProvider(workspace=workspace) self.assertIn("azure-quantum-qiskit", provider._workspace.user_agent) backend = provider.get_backend(RigettiTarget.QVM.value) - self.assertEqual(backend.name(), RigettiTarget.QVM.value) + self.assertEqual(backend.name, RigettiTarget.QVM.value) config = backend.configuration() self.assertTrue(config.simulator) self.assertEqual(1, config.max_experiments) @@ -1661,7 +2010,7 @@ def test_qiskit_get_rigetti_qpu_targets(self): warnings.warn(f"{msg}\nException:\n{QiskitBackendNotFoundError.__name__}\n{ex}") pytest.skip(msg) - self.assertEqual(backend.name(), RigettiTarget.ANKAA_3.value) + self.assertEqual(backend.name, RigettiTarget.ANKAA_3.value) config = backend.configuration() self.assertFalse(config.simulator) self.assertEqual(1, config.max_experiments) @@ -1671,6 +2020,32 @@ def test_qiskit_get_rigetti_qpu_targets(self): self.assertEqual("qir.v1", config.azure["input_data_format"]) self.assertEqual(MICROSOFT_OUTPUT_DATA_FORMAT_V2, backend._get_output_data_format()) + @pytest.mark.qci + def test_qci_qir_transpile_converts_non_qir_gates(self): + backend = QCISimulatorBackend(name="qci.simulator", provider=None) + + transpiled_ops = self._assert_qir_transpile_decomposes_non_qir_gates(backend) + self.assertGreater( + len(transpiled_ops - {"measure"}), + 0, + "Expected decomposed operations besides measurement.", + ) + + @pytest.mark.qci + def test_qci_transpile_supports_barrier(self): + backend = QCISimulatorBackend(name="qci.simulator", provider=None) + + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.barrier() + circuit.measure_all() + + self._assert_transpile_respects_target( + backend, + circuit, + expected_ops={"h", "barrier", "measure"}, + ) + @pytest.mark.skip("Skipping tests against QCI's unavailable targets") @pytest.mark.qci @pytest.mark.live_test @@ -1679,7 +2054,7 @@ def test_qiskit_submit_to_qci(self): provider = AzureQuantumProvider(workspace=workspace) self.assertIn("azure-quantum-qiskit", provider._workspace.user_agent) backend = provider.get_backend("qci.simulator") - self.assertEqual(backend.name(), "qci.simulator") + self.assertEqual(backend.name, "qci.simulator") config = backend.configuration() self.assertTrue(config.simulator) self.assertEqual(1, config.max_experiments) @@ -1759,7 +2134,7 @@ def test_qiskit_get_qci_qpu_targets(self): provider = AzureQuantumProvider(workspace=workspace) backend = provider.get_backend("qci.machine1") - self.assertEqual(backend.name(), "qci.machine1") + self.assertEqual(backend.name, "qci.machine1") config = backend.configuration() self.assertFalse(config.simulator) self.assertEqual(1, config.max_experiments) @@ -1818,14 +2193,7 @@ def test_backend_with_azure_config_format_defaults_to_that_format(self): ) actual = backend._get_output_data_format() self.assertEqual(expected, actual) - - def test_backend_without_azure_config_format_and_multiple_experiment_support_defaults_to_ms_format_v2( - self, - ): - backend = NoopQirBackend(None, "AzureQuantumProvider", **{"max_experiments": 2}) - output_data_format = backend._get_output_data_format() - self.assertEqual(output_data_format, MICROSOFT_OUTPUT_DATA_FORMAT_V2) - + def test_backend_with_azure_config_format_is_overridden_with_explicit_format(self): azure_congfig_value = "test_format" backend = NoopQirBackend( diff --git a/azure-quantum/tests/unit/test_session.py b/azure-quantum/tests/unit/test_session.py index 94f51c34..e98530f5 100644 --- a/azure-quantum/tests/unit/test_session.py +++ b/azure-quantum/tests/unit/test_session.py @@ -3,15 +3,11 @@ # Licensed under the MIT License. ## -from typing import Dict -import time import pytest from common import QuantumTestBase, DEFAULT_TIMEOUT_SECS from test_job_payload_factory import JobPayloadFactory from azure.quantum import Job, JobStatus, Session, SessionStatus, SessionJobFailurePolicy -from azure.quantum.qiskit.backends.quantinuum import QuantinuumQPUQirBackend -from azure.quantum.qiskit.provider import AzureQuantumProvider from import_qsharp import skip_if_no_qsharp @@ -145,43 +141,6 @@ def _test_session_job_cirq_circuit(self, target_name): session.refresh() self.assertEqual(session.details.status, SessionStatus.SUCCEEDED) - def _get_qiskit_backend(self, target_name): - from azure.quantum.qiskit import AzureQuantumProvider - workspace = self.create_workspace() - provider = AzureQuantumProvider(workspace=workspace) - if "echo-quantinuum" in target_name: - return EchoQuantinuumQPUQirBackend("echo-quantinuum", provider) - backend = provider.get_backend(target_name) - self.assertIsNotNone(backend) - return backend - - def _test_session_job_qiskit_circuit(self, target_name): - workspace = self.create_workspace() - backend = self._get_qiskit_backend(target_name) - circuit = JobPayloadFactory.get_qiskit_circuit_bell_state() - - with backend.open_session() as session: - self.assertEqual(session.details.status, SessionStatus.WAITING) - session_id = session.id - job1 = backend.run(circuit, shots=100, job_name="Job 1") - - backend.run(circuit, shots=100, job_name="Job 2") - - job1.wait_for_final_state(wait=5 if not self.is_playback else 0) - session.refresh() - - self.assertEqual(session.details.status, SessionStatus.EXECUTING) - - session = workspace.get_session(session_id=session_id) - session_jobs = session.list_jobs() - self.assertEqual(len(session_jobs), 2) - self.assertEqual(session_jobs[0].details.name, "Job 1") - self.assertEqual(session_jobs[1].details.name, "Job 2") - - [job.wait_until_completed(timeout_secs=DEFAULT_TIMEOUT_SECS) for job in session_jobs] - session.refresh() - self.assertEqual(session.details.status, SessionStatus.SUCCEEDED) - def _get_target(self, target_name): workspace = self.create_workspace() if "echo-quantinuum" in target_name: @@ -350,32 +309,6 @@ def test_session_job_cirq_circuit_quantinuum(self): def test_session_job_cirq_circuit_echo_quantinuum(self): self._test_session_job_cirq_circuit(target_name="echo-quantinuum") - # Session support for Qiskit jobs - - @pytest.mark.live_test - @pytest.mark.session - @pytest.mark.qiskit - @pytest.mark.ionq - @pytest.mark.xdist_group(name="ionq.simulator") - def test_session_job_qiskit_circuit_ionq(self): - self._test_session_job_qiskit_circuit(target_name="ionq.simulator") - - @pytest.mark.live_test - @pytest.mark.session - @pytest.mark.qiskit - @pytest.mark.quantinuum - @pytest.mark.xdist_group(name="quantinuum.sim.h2-1sc") - def test_session_job_qiskit_circuit_quantinuum(self): - self._test_session_job_qiskit_circuit(target_name="quantinuum.sim.h2-1sc") - - @pytest.mark.live_test - @pytest.mark.session - @pytest.mark.qiskit - @pytest.mark.echo_targets - @pytest.mark.xdist_group(name="echo-quantinuum") - def test_session_job_qiskit_circuit_echo_quantinuum(self): - self._test_session_job_qiskit_circuit(target_name="echo-quantinuum") - # Session support for Q# jobs @pytest.mark.live_test @@ -406,22 +339,3 @@ def test_session_job_qsharp_callable_echo_quantinuum(self): def test_session_job_qsharp_callable_ionq(self): self._test_session_job_qsharp_callable(target_name="ionq.simulator") - -class EchoQuantinuumQPUQirBackend(QuantinuumQPUQirBackend): - def _azure_config(self) -> Dict[str, str]: - config = super()._azure_config() - config.update( - { - "provider_id": ECHO_PROVIDER_NAME, - "output_data_format": "honeywell.qir.v1" - } - ) - return config - - def __init__(self, - name: str, - provider: AzureQuantumProvider, - **kwargs): - super().__init__(name=name, provider=provider) - self._provider_id = ECHO_PROVIDER_NAME - self._provider_name = ECHO_PROVIDER_NAME diff --git a/azure-quantum/tests/unit/test_session_qiskit.py b/azure-quantum/tests/unit/test_session_qiskit.py new file mode 100644 index 00000000..6c5d2d53 --- /dev/null +++ b/azure-quantum/tests/unit/test_session_qiskit.py @@ -0,0 +1,114 @@ +## +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +## + +from typing import Dict +import pytest + +from common import QuantumTestBase, DEFAULT_TIMEOUT_SECS +from test_job_payload_factory import JobPayloadFactory +from azure.quantum.qiskit.backends.quantinuum import QuantinuumQPUQirBackend +from azure.quantum.qiskit.provider import AzureQuantumProvider +from azure.quantum import SessionStatus + + +ECHO_PROVIDER_NAME = "Microsoft.Test.FirstParty" + + +class EchoQuantinuumQPUQirBackend(QuantinuumQPUQirBackend): + def _azure_config(self) -> Dict[str, str]: + config = super()._azure_config() + config.update( + { + "provider_id": ECHO_PROVIDER_NAME, + "output_data_format": "honeywell.qir.v1" + } + ) + return config + + def __init__(self, + name: str, + provider: "AzureQuantumProvider", + **kwargs): + super().__init__(name=name, provider=provider) + self._provider_id = ECHO_PROVIDER_NAME + self._provider_name = ECHO_PROVIDER_NAME + + +class QiskitTestSession(QuantumTestBase): + def _get_qiskit_backend(self, target_name): + from azure.quantum.qiskit import AzureQuantumProvider + workspace = self.create_workspace() + provider = AzureQuantumProvider(workspace=workspace) + if "echo-quantinuum" in target_name: + return EchoQuantinuumQPUQirBackend("echo-quantinuum", provider) + backend = provider.get_backend(target_name) + self.assertIsNotNone(backend) + return backend + + def _test_session_job_qiskit_circuit(self, target_name): + workspace = self.create_workspace() + backend = self._get_qiskit_backend(target_name) + circuit = JobPayloadFactory.get_qiskit_circuit_bell_state() + + with backend.open_session() as session: + self.assertEqual(session.details.status, SessionStatus.WAITING) + session_id = session.id + job1 = backend.run(circuit, shots=100, job_name="Job 1") + + backend.run(circuit, shots=100, job_name="Job 2") + + job1.wait_for_final_state(wait=5 if not self.is_playback else 0) + session.refresh() + + self.assertEqual(session.details.status, SessionStatus.EXECUTING) + + session = workspace.get_session(session_id=session_id) + session_jobs = session.list_jobs() + self.assertEqual(len(session_jobs), 2) + self.assertEqual(session_jobs[0].details.name, "Job 1") + self.assertEqual(session_jobs[1].details.name, "Job 2") + + [job.wait_until_completed(timeout_secs=DEFAULT_TIMEOUT_SECS) for job in session_jobs] + session.refresh() + self.assertEqual(session.details.status, SessionStatus.SUCCEEDED) + + def _get_target(self, target_name): + workspace = self.create_workspace() + if "echo-quantinuum" in target_name: + from azure.quantum.target.quantinuum import Quantinuum + target = Quantinuum(workspace=workspace, + name=target_name, + provider_id=ECHO_PROVIDER_NAME) + return target + target = workspace.get_targets(target_name) + self.assertIsNotNone(target) + return target + + + # Session support for Qiskit jobs + + @pytest.mark.live_test + @pytest.mark.session + @pytest.mark.qiskit + @pytest.mark.ionq + @pytest.mark.xdist_group(name="ionq.simulator") + def test_session_job_qiskit_circuit_ionq(self): + self._test_session_job_qiskit_circuit(target_name="ionq.simulator") + + @pytest.mark.live_test + @pytest.mark.session + @pytest.mark.qiskit + @pytest.mark.quantinuum + @pytest.mark.xdist_group(name="quantinuum.sim.h2-1sc") + def test_session_job_qiskit_circuit_quantinuum(self): + self._test_session_job_qiskit_circuit(target_name="quantinuum.sim.h2-1sc") + + @pytest.mark.live_test + @pytest.mark.session + @pytest.mark.qiskit + @pytest.mark.echo_targets + @pytest.mark.xdist_group(name="echo-quantinuum") + def test_session_job_qiskit_circuit_echo_quantinuum(self): + self._test_session_job_qiskit_circuit(target_name="echo-quantinuum") diff --git a/azure-quantum/tox.ini b/azure-quantum/tox.ini new file mode 100644 index 00000000..44287ce6 --- /dev/null +++ b/azure-quantum/tox.ini @@ -0,0 +1,17 @@ +[tox] +envlist = py{310,311,312,313}-qiskit{1,2} +skipsdist = true + +[testenv] +basepython = + py310: python3.10 + py311: python3.11 + py312: python3.12 + py313: python3.13 +allowlist_externals = pytest +deps = + qiskit1: qiskit<2 + qiskit2: qiskit>=2,<3 + -e .[qiskit] + -r requirements-dev.txt +commands = pytest --junitxml test-qiskit-{envname}.xml tests/unit/test_qiskit.py tests/unit/test_session_qiskit.py