diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 13c759584..adc90adf3 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -20,10 +20,20 @@ from dataclasses import asdict import warnings +try: + from qiskit_aer import AerSimulator + + HAS_AER_SIMULATOR = True +except ImportError: + HAS_AER_SIMULATOR = False + from qiskit.providers.options import Options as TerraOptions +from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit_ibm_provider.session import get_cm_session as get_cm_provider_session +from qiskit_ibm_runtime.fake_provider.fake_backend import FakeBackendV2 +from .fake_provider.fake_provider import FakeProviderForBackendV2 from .options import Options from .options.utils import set_default_error_levels from .runtime_job import RuntimeJob @@ -43,7 +53,7 @@ class BasePrimitive(ABC): def __init__( self, - backend: Optional[Union[str, IBMBackend]] = None, + backend: Optional[Union[str, IBMBackend, FakeBackendV2]] = None, session: Optional[Union[Session, str, IBMBackend]] = None, options: Optional[Union[Dict, Options]] = None, ): @@ -74,7 +84,10 @@ def __init__( # a nested dictionary to categorize options. self._session: Optional[Session] = None self._service: QiskitRuntimeService = None - self._backend: Optional[IBMBackend] = None + self._backend: Optional[Union[IBMBackend, FakeBackendV2]] = None + + if isinstance(backend, (AerSimulator, FakeBackendV2)) and not HAS_AER_SIMULATOR: + raise QiskitBackendNotFoundError("To use an Aer Simulator, you must install aer") if options is None: self._options = asdict(Options()) @@ -98,6 +111,13 @@ def __init__( if isinstance(backend, IBMBackend): self._service = backend.service self._backend = backend + elif isinstance(backend, (AerSimulator, FakeBackendV2)): + self._backend = backend + self._service = ( + QiskitRuntimeService() + if QiskitRuntimeService.global_service is None + else QiskitRuntimeService.global_service + ) elif isinstance(backend, str): self._service = ( QiskitRuntimeService() @@ -137,6 +157,11 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Returns: Submitted job. """ + run_simulator = isinstance(self._backend, AerSimulator) or ( + FakeProviderForBackendV2().backend(self._backend.name) is not None + and not isinstance(self._backend, IBMBackend) + ) + combined = Options._merge_options(self._options, user_kwargs) if self._backend: @@ -154,16 +179,27 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo combined = Options._set_default_resilience_options(combined) combined = Options._remove_none_values(combined) - primitive_inputs.update(Options._get_program_inputs(combined)) - if self._backend and combined["transpilation"]["skip_transpilation"]: + if self._backend and combined["transpilation"]["skip_transpilation"] and not run_simulator: for circ in primitive_inputs["circuits"]: self._backend.check_faulty(circ) logger.info("Submitting job using options %s", combined) runtime_options = Options._get_runtime_options(combined) + + if run_simulator: + # do we need to keep the other 'flat' options as well, + # for passing to terra directly? + primitive_inputs["optimization_level"] = combined["optimization_level"] + runtime_options["backend"] = self._backend + + return self._service.run( + program_id=self._program_id(), + options=runtime_options, + inputs=primitive_inputs, + ) if self._session: return self._session.run( program_id=self._program_id(), diff --git a/qiskit_ibm_runtime/fake_provider/fake_provider.py b/qiskit_ibm_runtime/fake_provider/fake_provider.py index 4c3becb49..73ed9935d 100644 --- a/qiskit_ibm_runtime/fake_provider/fake_provider.py +++ b/qiskit_ibm_runtime/fake_provider/fake_provider.py @@ -16,8 +16,11 @@ Fake provider class that provides access to fake backends. """ +from typing import List + from qiskit.providers.provider import ProviderV1 from qiskit.providers.exceptions import QiskitBackendNotFoundError +from qiskit.providers.fake_provider.fake_backend import FakeBackendV2 from .backends import * @@ -69,21 +72,15 @@ class FakeProviderForBackendV2(ProviderV1): available in the :mod:`qiskit_ibm_runtime.fake_provider`. """ - def backend(self, name=None, **kwargs): # type: ignore - """ - Filter backends in provider by name. - """ - backend = self._backends[0] + def backend(self, name: str = None, **kwargs) -> FakeBackendV2: # type: ignore + """Return the backend according to its name. If kwargs are defined, then + additional filters may be applied. If no such backend, return None.""" if name: - filtered_backends = [backend for backend in self._backends if backend.name() == name] - if not filtered_backends: - raise QiskitBackendNotFoundError() - - backend = filtered_backends[0] - + filtered_backends = [backend for backend in self.backends() if backend.name == name] + backend = filtered_backends[0] if len(filtered_backends) > 0 else None return backend - def backends(self, name=None, **kwargs): # type: ignore + def backends(self, name=None, **kwargs) -> List[FakeBackendV2]: # type: ignore return self._backends def __init__(self) -> None: diff --git a/qiskit_ibm_runtime/fake_runtime_job.py b/qiskit_ibm_runtime/fake_runtime_job.py new file mode 100644 index 000000000..492105f34 --- /dev/null +++ b/qiskit_ibm_runtime/fake_runtime_job.py @@ -0,0 +1,98 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Qiskit fake runtime job.""" + +from typing import Optional, Dict, Any, List +from datetime import datetime + +from qiskit.primitives.primitive_job import PrimitiveJob +from qiskit.providers import JobStatus, JobV1 +from qiskit.providers.fake_provider import FakeBackendV2 as FakeBackend + +# pylint: disable=cyclic-import +# from .qiskit_runtime_service import QiskitRuntimeService + + +class FakeRuntimeJob(JobV1): + """Representation of a runtime program execution on a simulator.""" + + def __init__( + self, + primitive_job: PrimitiveJob, + backend: FakeBackend, + job_id: str, + program_id: str, + params: Optional[Dict] = None, + creation_date: Optional[str] = None, + tags: Optional[List] = None, + ) -> None: + """FakeRuntimeJob constructor.""" + super().__init__(backend=backend, job_id=job_id) + self._primitive_job = primitive_job + self._job_id = job_id + self._params = params or {} + self._program_id = program_id + self._creation_date = creation_date + self._tags = tags + + def result(self) -> Any: + """Return the results of the job.""" + return self._primitive_job.result() + + def cancel(self) -> None: + self._primitive_job.cancel() + + def status(self) -> JobStatus: + return self._primitive_job.status() + + @property + def inputs(self) -> Dict: + """Job input parameters. + + Returns: + Input parameters used in this job. + """ + return self._params + + @property + def creation_date(self) -> Optional[datetime]: + """Job creation date in local time. + + Returns: + The job creation date as a datetime object, in local time, or + ``None`` if creation date is not available. + """ + return self._creation_date + + @property + def tags(self) -> List: + """Job tags. + + Returns: + Tags assigned to the job that can be used for filtering. + """ + return self._tags + + def submit(self) -> None: + """Unsupported method. + Note: + This method is not supported, please use + :meth:`~qiskit_ibm_runtime.QiskitRuntimeService.run` + to submit a job. + Raises: + NotImplementedError: Upon invocation. + """ + raise NotImplementedError( + "job.submit() is not supported. Please use " + "QiskitRuntimeService.run() to submit a job." + ) diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 3a744f5b5..6b6843d5a 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,6 +12,7 @@ """Utility functions for options.""" +from qiskit_ibm_runtime.fake_provider.fake_backend import FakeBackendV2 from ..ibm_backend import IBMBackend @@ -32,20 +33,17 @@ def set_default_error_levels( Returns: options with correct error level defaults. """ + # The check that this is not a FakeBackendV2 is needed because FakeBackendV2 + # does not have a configuration + is_simulator = not isinstance(backend, FakeBackendV2) and backend.configuration().simulator if options.get("optimization_level") is None: - if ( - backend.configuration().simulator - and options.get("simulator", {}).get("noise_model") is None - ): + if is_simulator and options.get("simulator", {}).get("noise_model") is None: options["optimization_level"] = 1 else: options["optimization_level"] = default_optimization_level if options.get("resilience_level") is None: - if ( - backend.configuration().simulator - and options.get("simulator", {}).get("noise_model") is None - ): + if is_simulator and options.get("simulator", {}).get("noise_model") is None: options["resilience_level"] = 0 else: options["resilience_level"] = default_resilience_level diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index 9566138ff..cb61bdf4a 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -20,6 +20,15 @@ from collections import OrderedDict from typing import Dict, Callable, Optional, Union, List, Any, Type, Sequence +try: + from qiskit_aer import AerSimulator + + HAS_AER_SIMULATOR = True +except ImportError: + HAS_AER_SIMULATOR = False + +from qiskit_aer.primitives import Sampler as AerSampler +from qiskit_aer.primitives import Estimator as AerEstimator from qiskit.providers.backend import BackendV1 as Backend from qiskit.providers.provider import ProviderV1 as Provider from qiskit.providers.exceptions import QiskitBackendNotFoundError @@ -28,12 +37,16 @@ PulseBackendConfiguration, QasmBackendConfiguration, ) +from qiskit.primitives import BackendSampler, BackendEstimator from qiskit_ibm_provider.proxies import ProxyConfiguration from qiskit_ibm_provider.utils.hgp import to_instance_format, from_instance_format from qiskit_ibm_provider.utils.backend_decoder import configuration_from_server_data -from qiskit_ibm_runtime import ibm_backend +from qiskit_ibm_runtime import ibm_backend +from qiskit_ibm_runtime.fake_provider.fake_backend import FakeBackendV2 +from .fake_runtime_job import FakeRuntimeJob +from .fake_provider.fake_provider import FakeProviderForBackendV2 from .utils.utils import validate_job_tags from .accounts import AccountManager, Account, ChannelType from .api.clients import AuthClient, VersionClient @@ -540,7 +553,7 @@ def backends( instance: Optional[str] = None, filters: Optional[Callable[[List["ibm_backend.IBMBackend"]], bool]] = None, **kwargs: Any, - ) -> List["ibm_backend.IBMBackend"]: + ) -> Union[List["ibm_backend.IBMBackend"], List[FakeProviderForBackendV2]]: """Return all backends accessible via this account, subject to optional filtering. Args: @@ -580,11 +593,15 @@ def backends( QiskitBackendNotFoundError: If the backend is not in any instance. """ # TODO filter out input_allowed not having runtime - backends: List[IBMBackend] = [] + backends: List[Union[IBMBackend, FakeProviderForBackendV2]] = [] instance_filter = instance if instance else self._account.instance if self._channel == "ibm_quantum": if name: if name not in self._backends: + backend = self._get_fake_backend(name) + if backend: + backends.append(backend) + return backends raise QiskitBackendNotFoundError("No backend matches the criteria.") if not self._backends[name] or instance_filter != self._backends[name]._instance: self._set_backend_config(name) @@ -799,7 +816,7 @@ def backend( self, name: str = None, instance: Optional[str] = None, - ) -> Backend: + ) -> Union[Backend, FakeProviderForBackendV2]: """Return a single backend matching the specified filtering. Args: @@ -816,6 +833,11 @@ def backend( QiskitBackendNotFoundError: if no backend could be found. """ # pylint: disable=arguments-differ, line-too-long + if name and name not in self._backends: + backend = self._get_fake_backend(name) + if backend: + return backend + backends = self.backends(name, instance=instance) if not backends: cloud_msg_url = "" @@ -830,6 +852,10 @@ def backend( def get_backend(self, name: str = None, **kwargs: Any) -> Backend: return self.backend(name, **kwargs) + def _get_fake_backend(self, name: str) -> FakeProviderForBackendV2: + backend = FakeProviderForBackendV2().backend(name) + return backend + def run( self, program_id: str, @@ -839,7 +865,7 @@ def run( result_decoder: Optional[Union[Type[ResultDecoder], Sequence[Type[ResultDecoder]]]] = None, session_id: Optional[str] = None, start_session: Optional[bool] = False, - ) -> RuntimeJob: + ) -> Union[RuntimeJob, FakeRuntimeJob]: """Execute the runtime program. Args: @@ -869,6 +895,8 @@ def run( IBMInputValueError: If input is invalid. RuntimeProgramNotFound: If the program cannot be found. IBMRuntimeError: An error occurred running the program. + QiskitBackendNotFoundError: If backend is an AerSimulator or FakeBackendV2, + and aer is not installed. """ qrt_options: RuntimeOptions = options if options is None: @@ -877,6 +905,90 @@ def run( qrt_options = RuntimeOptions(**options) qrt_options.validate(channel=self.channel) + sim_options = inputs.get("run_options", None) if inputs else None + + transpile_options = {} + transpile_options["optimization_level"] = ( + inputs.get("optimization_level", None) if inputs else None + ) # do we need additional options + + is_fake_backend = False + is_aer_backend = False + + if isinstance(qrt_options.backend, (AerSimulator, FakeBackendV2)) and not HAS_AER_SIMULATOR: + raise QiskitBackendNotFoundError("To use an Aer Simulator, you must install aer") + + # if backend is a simulator, run locally + if isinstance(qrt_options.backend, AerSimulator): + is_aer_backend = True + if program_id == "sampler": + prog = AerSampler + else: # program_id == "estimator": + prog = AerEstimator + + if ( + not isinstance(qrt_options.backend, IBMBackend) + and not isinstance(qrt_options.backend, str) + and FakeProviderForBackendV2().backend(qrt_options.backend.name) + is not None # type: ignore + ): + is_fake_backend = True # type: ignore + if program_id == "sampler": + prog = BackendSampler + else: # program_id == "estimator": + prog = BackendEstimator + # pylint: disable=unexpected-keyword-arg + if is_fake_backend: + my_program = prog( + backend=qrt_options.backend, + options=sim_options, + skip_transpilation=inputs["transpilation_settings"]["skip_transpilation"], + ) + my_program.set_transpile_options(**transpile_options) + observables = inputs.get("observables", None) + primitive_job = my_program._run( + circuits=inputs["circuits"], + observables=observables, + parameter_values=inputs["parameters"], + **sim_options, + ) + fake_runtime_job = FakeRuntimeJob( + primitive_job=primitive_job, + backend=qrt_options.backend, + job_id=primitive_job.job_id(), + program_id=program_id, + params=inputs["parameters"], + creation_date=datetime.now(), + tags=qrt_options.job_tags, + ) + return fake_runtime_job + + if is_aer_backend: + aer_backend_options = qrt_options.backend._options # type:ignore + for opt in inputs["run_options"]: + if hasattr(AerSimulator._default_options(), opt): + aer_backend_options[opt] = inputs["run_options"][opt] + my_program = prog( + backend_options=aer_backend_options, + transpile_options=transpile_options, + ) + observables = inputs.get("observables", None) + primitive_job = my_program._run( + circuits=inputs["circuits"], + observables=observables, + parameter_values=inputs["parameters"], + **sim_options, + ) + fake_runtime_job = FakeRuntimeJob( + primitive_job=primitive_job, + backend=qrt_options.backend, + job_id=primitive_job.job_id(), + program_id=program_id, + params=inputs["parameters"], + creation_date=datetime.now(), + tags=qrt_options.job_tags, + ) + return fake_runtime_job hgp_name = None if self._channel == "ibm_quantum": @@ -924,7 +1036,6 @@ def run( if response["backend"] else qrt_options.backend ) - job = RuntimeJob( backend=backend, api_client=self._api_client, diff --git a/releasenotes/notes/local_simulation-0e51bf28311f8591.yaml b/releasenotes/notes/local_simulation-0e51bf28311f8591.yaml new file mode 100644 index 000000000..a89b696d4 --- /dev/null +++ b/releasenotes/notes/local_simulation-0e51bf28311f8591.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + When running on ``AerSimulator`` or on a ``FakeProviderForBackendV2``, the program will now locally + rather than running on the IBM Cloud. diff --git a/test/unit/test_data_serialization.py b/test/unit/test_data_serialization.py index 8011c6eff..40d7f824b 100644 --- a/test/unit/test_data_serialization.py +++ b/test/unit/test_data_serialization.py @@ -246,7 +246,7 @@ def test_result_decoder(self): decoder=result_decoder, ) with mock_wait_for_final_state(ibm_quantum_service, job): - result = job.result(decoder=decoder) + result = job.result(decoder=decoder) # pylint: disable=unexpected-keyword-arg self.assertIsInstance(result["serializable_class"], SerializableClass) def test_circuit_metadata(self): diff --git a/test/unit/test_run_simulation.py b/test/unit/test_run_simulation.py new file mode 100644 index 000000000..80cf229c2 --- /dev/null +++ b/test/unit/test_run_simulation.py @@ -0,0 +1,90 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for running locally on a simulator.""" + +import unittest + +from qiskit_aer import AerSimulator +from qiskit.quantum_info import SparsePauliOp +from qiskit.utils import optionals +from qiskit.test.reference_circuits import ReferenceCircuits + +# pylint: disable=unused-import +from qiskit_ibm_runtime import QiskitRuntimeService, Sampler, Estimator +from qiskit_ibm_runtime.fake_provider.backends.almaden import FakeAlmadenV2 + +from ..ibm_test_case import IBMTestCase +from .mock.fake_runtime_service import FakeRuntimeService + + +class TestRunSimulation(IBMTestCase): + """Tests for local execution on simulators""" + + @unittest.skipUnless(optionals.HAS_AER, "qiskit-aer is required to run this test") + def test_basic_flow(self): + """Test basic flow on simulator.""" + # pylint: disable=unused-variable + service = FakeRuntimeService(channel="ibm_quantum", token="my_token") + shots = 100 + circuit = ReferenceCircuits.bell() + for backend in ["fake_manila", AerSimulator(), FakeAlmadenV2()]: + sampler = Sampler(backend=backend) + job = sampler.run(circuit, shots=shots) + result = job.result() + self.assertAlmostEqual(result.quasi_dists[0][0], 0.5, delta=0.2) + self.assertAlmostEqual(result.quasi_dists[0][3], 0.5, delta=0.2) + self.assertEqual(result.metadata[0]["shots"], shots) + + estimator = Estimator(backend=backend) + obs = SparsePauliOp("ZZ") + job = estimator.run([circuit], observables=obs, shots=shots) + result = job.result() + self.assertAlmostEqual(result.values[0], 1.0, delta=0.01) + self.assertEqual(result.metadata[0]["shots"], shots) + + @unittest.skipUnless(optionals.HAS_AER, "qiskit-aer is required to run this test") + def test_aer_sim_options(self): + """Test that options to Aer simulator are passed properly""" + # pylint: disable=unused-variable + service = FakeRuntimeService(channel="ibm_quantum", token="my_token") + + shots = 100 + circuit = ReferenceCircuits.bell() + sim_methods = [ + "statevector", + "density_matrix", + "stabilizer", + "extended_stabilizer", + "matrix_product_state", + ] + for method in sim_methods: + backend = AerSimulator(method=method) + sampler = Sampler(backend=backend) + job = sampler.run(circuit, shots=shots) + result = job.result() + self.assertEqual(result.metadata[0]["simulator_metadata"]["method"], method) + + def test_run_backend_on_simulator(self): + """Test backend.run() on a simulator""" + # pylint: disable=unused-variable + service = FakeRuntimeService(channel="ibm_quantum", token="my_token") + shots = 100 + seed_simulator = 123 + circuit = ReferenceCircuits.bell() + for backend in [FakeAlmadenV2(), AerSimulator()]: + result = backend.run( + circuit, seed_simulator=seed_simulator, shots=shots, method="statevector" + ).result() + self.assertEqual(result.results[0].shots, shots) + self.assertEqual(result.results[0].seed_simulator, seed_simulator) + self.assertEqual(result.results[0].metadata["method"], "statevector")