Source code for pytket.backends.backend

# Copyright 2019-2024 Cambridge Quantum Computing
#
# 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.

""" Abstract base class for all Backend encapsulations."""
import warnings
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sequence
from importlib import import_module
from types import ModuleType
from typing import Any, Literal, cast, overload

from pytket.circuit import Bit, Circuit, OpType
from pytket.passes import BasePass
from pytket.pauli import QubitPauliString
from pytket.predicates import Predicate
from pytket.utils import QubitPauliOperator
from pytket.utils.outcomearray import OutcomeArray
from pytket.utils.results import KwargTypes

from .backend_exceptions import (
    CircuitNotRunError,
    CircuitNotValidError,
)
from .backendinfo import BackendInfo
from .backendresult import BackendResult
from .resulthandle import ResultHandle, _ResultIdTuple
from .status import CircuitStatus

ResultCache = dict[str, Any]


class ResultHandleTypeError(Exception):
    """Wrong result handle type."""


[docs] class Backend(ABC): """ This abstract class defines the structure of a backend as something that can run quantum circuits and produce output as at least one of shots, counts, state, or unitary """ _supports_shots = False _supports_counts = False _supports_state = False _supports_unitary = False _supports_density_matrix = False _supports_expectation = False _expectation_allows_nonhermitian = False _supports_contextual_optimisation = False _persistent_handles = False
[docs] def __init__(self) -> None: self._cache: dict[ResultHandle, ResultCache] = {}
@staticmethod def empty_result(circuit: Circuit, n_shots: int) -> BackendResult: n_bits = len(circuit.bits) empty_readouts = [[0] * n_bits for _ in range(n_shots)] shots = OutcomeArray.from_readouts(empty_readouts) c_bits = [Bit(index) for index in range(n_bits)] return BackendResult(shots=shots, c_bits=c_bits) @property @abstractmethod def required_predicates(self) -> list[Predicate]: """ The minimum set of predicates that a circuit must satisfy before it can be successfully run on this backend. :return: Required predicates. :rtype: List[Predicate] """ ...
[docs] def valid_circuit(self, circuit: Circuit) -> bool: """ Checks that the circuit satisfies all of required_predicates. :param circuit: The circuit to check. :type circuit: Circuit :return: Whether or not all of required_predicates are satisfied. :rtype: bool """ return all(pred.verify(circuit) for pred in self.required_predicates)
def _check_all_circuits( self, circuits: Iterable[Circuit], nomeasure_warn: bool | None = None ) -> bool: if nomeasure_warn is None: nomeasure_warn = not ( self._supports_state or self._supports_unitary or self._supports_density_matrix or self._supports_expectation ) for i, circ in enumerate(circuits): errors = ( CircuitNotValidError(i, repr(pred)) for pred in self.required_predicates if not pred.verify(circ) ) for error in errors: raise error if nomeasure_warn: if circ.n_gates_of_type(OpType.Measure) < 1: warnings.warn( f"Circuit with index {i} in submitted does not contain a " "measure operation." ) return True
[docs] @abstractmethod def rebase_pass(self) -> BasePass: """ A single compilation pass that when run converts all gates in a Circuit to an OpType supported by the Backend (ignoring architecture constraints). :return: Compilation pass that converts gates to primitives supported by Backend. :rtype: BasePass """ ...
[docs] @abstractmethod def default_compilation_pass(self, optimisation_level: int = 2) -> BasePass: """ A suggested compilation pass that will will, if possible, produce an equivalent circuit suitable for running on this backend. At a minimum it will ensure that compatible gates are used and that all two- qubit interactions are compatible with the backend's qubit architecture. At higher optimisation levels, further optimisations may be applied. This is a an abstract method which is implemented in the backend itself, and so is tailored to the backend's requirements. :param optimisation_level: The level of optimisation to perform during compilation. - Level 0 does the minimum required to solves the device constraints, without any optimisation. - Level 1 additionally performs some light optimisations. - Level 2 (the default) adds more computationally intensive optimisations that should give the best results from execution. :type optimisation_level: int, optional :return: Compilation pass guaranteeing required predicates. :rtype: BasePass """ ...
[docs] def get_compiled_circuit( self, circuit: Circuit, optimisation_level: int = 2 ) -> Circuit: """ Return a single circuit compiled with :py:meth:`default_compilation_pass`. See :py:meth:`Backend.get_compiled_circuits`. """ return_circuit = circuit.copy() self.default_compilation_pass(optimisation_level).apply(return_circuit) return return_circuit
[docs] def get_compiled_circuits( self, circuits: Sequence[Circuit], optimisation_level: int = 2 ) -> list[Circuit]: """Compile a sequence of circuits with :py:meth:`default_compilation_pass` and return the list of compiled circuits (does not act in place). As well as applying a degree of optimisation (controlled by the `optimisation_level` parameter), this method tries to ensure that the circuits can be run on the backend (i.e. successfully passed to :py:meth:`process_circuits`), for example by rebasing to the supported gate set, or routing to match the connectivity of the device. However, this is not always possible, for example if the circuit contains classical operations that are not supported by the backend. You may use :py:meth:`valid_circuit` to check whether the circuit meets the backend's requirements after compilation. This validity check is included in :py:meth:`process_circuits` by default, before any circuits are submitted to the backend. If the validity check fails, you can obtain more information about the failure by iterating through the predicates in the `required_predicates` property of the backend, and running the :py:meth:`verify` method on each in turn with your circuit. :param circuits: The circuits to compile. :type circuit: Sequence[Circuit] :param optimisation_level: The level of optimisation to perform during compilation. See :py:meth:`default_compilation_pass` for a description of the different levels (0, 1 or 2). Defaults to 2. :type optimisation_level: int, optional :return: Compiled circuits. :rtype: List[Circuit] """ return [self.get_compiled_circuit(c, optimisation_level) for c in circuits]
@property @abstractmethod def _result_id_type(self) -> _ResultIdTuple: """Identifier type signature for ResultHandle for this backend. :return: Type signature (tuple of hashable types) :rtype: _ResultIdTuple """ ... def _check_handle_type(self, reshandle: ResultHandle) -> None: """Check a result handle is valid for this backend, raises TypeError if not. :param reshandle: Handle to check :type reshandle: ResultHandle :raises TypeError: Types of handle identifiers don't match those of backend. """ if (len(reshandle) != len(self._result_id_type)) or not all( isinstance(idval, ty) for idval, ty in zip(reshandle, self._result_id_type) ): raise ResultHandleTypeError( f"{reshandle!r} does not match expected " f"identifier types {self._result_id_type}" )
[docs] def process_circuit( self, circuit: Circuit, n_shots: int | None = None, valid_check: bool = True, **kwargs: KwargTypes, ) -> ResultHandle: """ Submit a single circuit to the backend for running. See :py:meth:`Backend.process_circuits`. """ return self.process_circuits( [circuit], n_shots=n_shots, valid_check=valid_check, **kwargs )[0]
[docs] @abstractmethod def process_circuits( self, circuits: Sequence[Circuit], n_shots: int | Sequence[int] | None = None, valid_check: bool = True, **kwargs: KwargTypes, ) -> list[ResultHandle]: """ Submit circuits to the backend for running. The results will be stored in the backend's result cache to be retrieved by the corresponding get_<data> method. If the `postprocess` keyword argument is set to True, and the backend supports the feature (see :py:meth:`supports_contextual_optimisation`), then contextual optimisatioons are applied before running the circuit and retrieved results will have any necessary classical postprocessing applied. This is not enabled by default. Use keyword arguments to specify parameters to be used in submitting circuits See specific Backend derived class for available parameters, from the following list: * `seed`: RNG seed for simulators * `postprocess`: if True, apply contextual optimisations Note: If a backend is reused many times, the in-memory results cache grows indefinitely. Therefore, when processing many circuits on a statevector or unitary backend (whose results may occupy significant amounts of memory), it is advisable to run :py:meth:`Backend.empty_cache` after each result is retrieved. :param circuits: Circuits to process on the backend. :type circuits: Sequence[Circuit] :param n_shots: Number of shots to run per circuit. Optionally, this can be a list of shots specifying the number of shots for each circuit separately. None is to be used for state/unitary simulators. Defaults to None. :type n_shots: Optional[Union[int, Iterable[int]], optional :param valid_check: Explicitly check that all circuits satisfy all required predicates to run on the backend. Defaults to True :type valid_check: bool, optional :return: Handles to results for each input circuit, as an interable in the same order as the circuits. :rtype: List[ResultHandle] """ ...
[docs] @abstractmethod def circuit_status(self, handle: ResultHandle) -> CircuitStatus: """ Return a CircuitStatus reporting the status of the circuit execution corresponding to the ResultHandle """ ...
[docs] def empty_cache(self) -> None: """Manually empty the result cache on the backend.""" self._cache = {}
[docs] def pop_result(self, handle: ResultHandle) -> ResultCache | None: """Remove cache entry corresponding to handle from the cache and return. :param handle: ResultHandle object :type handle: ResultHandle :return: Cache entry corresponding to handle, if it was present :rtype: Optional[ResultCache] """ return self._cache.pop(handle, None)
[docs] def get_result(self, handle: ResultHandle, **kwargs: KwargTypes) -> BackendResult: """Return a BackendResult corresponding to the handle. Use keyword arguments to specify parameters to be used in retrieving results. See specific Backend derived class for available parameters, from the following list: * `timeout`: maximum time to wait for remote job to finish * `wait`: polling interval between remote calls to check job status :param handle: handle to results :type handle: ResultHandle :return: Results corresponding to handle. :rtype: BackendResult """ self._check_handle_type(handle) if handle in self._cache and "result" in self._cache[handle]: return cast(BackendResult, self._cache[handle]["result"]) raise CircuitNotRunError(handle)
[docs] def get_results( self, handles: Iterable[ResultHandle], **kwargs: KwargTypes ) -> list[BackendResult]: """Return results corresponding to handles. :param handles: Iterable of handles :return: List of results Keyword arguments are as for `get_result`, and apply to all jobs. """ try: return [self.get_result(handle, **kwargs) for handle in handles] except ResultHandleTypeError as e: try: self._check_handle_type(cast(ResultHandle, handles)) except ResultHandleTypeError: raise e raise ResultHandleTypeError( "Possible use of single ResultHandle" " where sequence of ResultHandles was expected." ) from e
[docs] def run_circuit( self, circuit: Circuit, n_shots: int | None = None, valid_check: bool = True, **kwargs: KwargTypes, ) -> BackendResult: """ Submits a circuit to the backend and returns results :param circuit: Circuit to be executed :param n_shots: Passed on to :py:meth:`Backend.process_circuit` :param valid_check: Passed on to :py:meth:`Backend.process_circuit` :return: Result This is a convenience method equivalent to calling :py:meth:`Backend.process_circuit` followed by :py:meth:`Backend.get_result`. Any additional keyword arguments are passed on to :py:meth:`Backend.process_circuit` and :py:meth:`Backend.get_result`. """ return self.run_circuits( [circuit], n_shots=n_shots, valid_check=valid_check, **kwargs )[0]
[docs] def run_circuits( self, circuits: Sequence[Circuit], n_shots: int | Sequence[int] | None = None, valid_check: bool = True, **kwargs: KwargTypes, ) -> list[BackendResult]: """ Submits circuits to the backend and returns results :param circuits: Sequence of Circuits to be executed :param n_shots: Passed on to :py:meth:`Backend.process_circuits` :param valid_check: Passed on to :py:meth:`Backend.process_circuits` :return: List of results This is a convenience method equivalent to calling :py:meth:`Backend.process_circuits` followed by :py:meth:`Backend.get_results`. Any additional keyword arguments are passed on to :py:meth:`Backend.process_circuits` and :py:meth:`Backend.get_results`. """ handles = self.process_circuits(circuits, n_shots, valid_check, **kwargs) results = self.get_results(handles, **kwargs) for h in handles: self.pop_result(h) return results
[docs] def cancel(self, handle: ResultHandle) -> None: """ Cancel a job. :param handle: handle to job :type handle: ResultHandle :raises NotImplementedError: If backend does not support job cancellation """ raise NotImplementedError("Backend does not support job cancellation.")
@property def backend_info(self) -> BackendInfo | None: """Retrieve all Backend properties in a BackendInfo object, including device architecture, supported gate set, gate errors and other hardware-specific information. :return: The BackendInfo describing this backend if it exists. :rtype: Optional[BackendInfo] """ raise NotImplementedError("Backend does not provide any device properties.")
[docs] @classmethod def available_devices(cls, **kwargs: Any) -> list[BackendInfo]: """Retrieve all available devices as a list of BackendInfo objects, including device name, architecture, supported gate set, gate errors, and other hardware-specific information. :return: A list of BackendInfo objects describing available devices. :rtype: List[BackendInfo] """ raise NotImplementedError( "Backend does not provide information about available devices." )
@property def persistent_handles(self) -> bool: """ Whether the backend produces `ResultHandle` objects that can be reused with other instances of the backend class. """ return self._persistent_handles @property def supports_shots(self) -> bool: """ Does this backend support shot result retrieval via :py:meth:`backendresult.BackendResult.get_shots`. """ return self._supports_shots @property def supports_counts(self) -> bool: """ Does this backend support counts result retrieval via :py:meth:`backendresult.BackendResult.get_counts`. """ return self._supports_counts @property def supports_state(self) -> bool: """ Does this backend support statevector retrieval via :py:meth:`backendresult.BackendResult.get_state`. """ return self._supports_state @property def supports_unitary(self) -> bool: """ Does this backend support unitary retrieval via :py:meth:`backendresult.BackendResult.get_unitary`. """ return self._supports_unitary @property def supports_density_matrix(self) -> bool: """Does this backend support density matrix retrieval via `get_density_matrix`.""" return self._supports_density_matrix @property def supports_expectation(self) -> bool: """Does this backend support expectation value calculation for operators.""" return self._supports_expectation @property def expectation_allows_nonhermitian(self) -> bool: """If expectations are supported, is the operator allowed to be non-Hermitan?""" return self._expectation_allows_nonhermitian @property def supports_contextual_optimisation(self) -> bool: """Does this backend support contextual optimisation? See :py:meth:`process_circuits`.""" return self._supports_contextual_optimisation def _get_extension_module(self) -> ModuleType | None: """Return the extension module of the backend if it belongs to a pytket-extension package. :return: The extension module of the backend if it belongs to a pytket-extension package. :rtype: Optional[ModuleType] """ mod_parts = self.__class__.__module__.split(".")[:3] if not (mod_parts[0] == "pytket" and mod_parts[1] == "extensions"): return None return import_module(".".join(mod_parts)) @property def __extension_name__(self) -> str | None: """Retrieve the extension name of the backend if it belongs to a pytket-extension package. :return: The extension name of the backend if it belongs to a pytket-extension package. :rtype: Optional[str] """ try: return self._get_extension_module().__extension_name__ # type: ignore except AttributeError: return None @property def __extension_version__(self) -> str | None: """Retrieve the extension version of the backend if it belongs to a pytket-extension package. :return: The extension version of the backend if it belongs to a pytket-extension package. :rtype: Optional[str] """ try: return self._get_extension_module().__extension_version__ # type: ignore except AttributeError: return None @overload @staticmethod def _get_n_shots_as_list( n_shots: None | int | Sequence[int | None], n_circuits: int, optional: Literal[False], ) -> list[int]: ... @overload @staticmethod def _get_n_shots_as_list( n_shots: None | int | Sequence[int | None], n_circuits: int, optional: Literal[True], set_zero: Literal[True], ) -> list[int]: ... @overload @staticmethod def _get_n_shots_as_list( n_shots: None | int | Sequence[int | None], n_circuits: int, optional: bool = True, set_zero: bool = False, ) -> list[int | None] | list[int]: ... @staticmethod def _get_n_shots_as_list( n_shots: None | int | Sequence[int | None], n_circuits: int, optional: bool = True, set_zero: bool = False, ) -> list[int | None] | list[int]: """ Convert any admissible n_shots value into List[Optional[int]] format. This validates the n_shots argument for process_circuits. If a single value is passed, this value is broadcast to the number of circuits. Additional boolean flags control how the argument is validated. Raises an exception if n_shots is in an invalid format. :param n_shots: The argument to be validated. :type n_shots: Union[None, int, Sequence[Optional[int]]] :param n_circuits: Length of the converted argument returned. :type n_circuits: int :param optional: Whether n_shots can be None (default: True). :type optional: bool :param set_zero: Whether None values should be set to 0 (default: False). :type set_zero: bool :return: a list of length `n_circuits`, the converted argument """ n_shots_list: list[int | None] = [] def validate_n_shots(n: int | None) -> bool: return optional or (n is not None and n > 0) if set_zero and not optional: ValueError("set_zero cannot be true when optional is false") if hasattr(n_shots, "__iter__"): assert not isinstance(n_shots, int) assert n_shots is not None if not all(map(validate_n_shots, n_shots)): raise ValueError( "n_shots values are required for all circuits for this backend" ) n_shots_list = list(n_shots) else: assert n_shots is None or isinstance(n_shots, int) if not validate_n_shots(n_shots): raise ValueError("Parameter n_shots is required for this backend") # convert n_shots to a list n_shots_list = [n_shots] * n_circuits if len(n_shots_list) != n_circuits: raise ValueError("The length of n_shots and circuits must match") if set_zero: # replace None with 0 n_shots_list = list(map(lambda n: n or 0, n_shots_list)) return n_shots_list def get_pauli_expectation_value( self, state_circuit: Circuit, pauli: QubitPauliString ) -> complex: raise NotImplementedError def get_operator_expectation_value( self, state_circuit: Circuit, operator: QubitPauliOperator ) -> complex: raise NotImplementedError