From e0aed7082289fb479496666e6d46e49458777271 Mon Sep 17 00:00:00 2001 From: Naoki Kanazawa Date: Tue, 11 Jun 2024 21:29:28 +0900 Subject: [PATCH] Remove backend cache from QiskitRuntimeService (#1732) * wip, remove cache * fix test failure * add release note * remove early return * remove discover_backends * cache configuration * remove upgrade note * mypy * add release note --------- Co-authored-by: Jessie Yu --- qiskit_ibm_runtime/api/clients/runtime.py | 7 +- qiskit_ibm_runtime/qiskit_runtime_service.py | 207 +++++++------------ release-notes/unreleased/1732.upgrade.rst | 5 + test/unit/mock/fake_runtime_client.py | 12 +- test/unit/test_backend_retrieval.py | 20 ++ 5 files changed, 119 insertions(+), 132 deletions(-) create mode 100644 release-notes/unreleased/1732.upgrade.rst diff --git a/qiskit_ibm_runtime/api/clients/runtime.py b/qiskit_ibm_runtime/api/clients/runtime.py index 82b1d6814..4bad22c5f 100644 --- a/qiskit_ibm_runtime/api/clients/runtime.py +++ b/qiskit_ibm_runtime/api/clients/runtime.py @@ -45,6 +45,7 @@ def __init__( **params.connection_parameters(), ) self._api = Runtime(self._session) + self._configuration_registry: Dict[str, Dict[str, Any]] = {} def program_run( self, @@ -301,7 +302,11 @@ def backend_configuration(self, backend_name: str) -> Dict[str, Any]: Returns: Backend configuration. """ - return self._api.backend(backend_name).configuration() + if backend_name not in self._configuration_registry: + self._configuration_registry[backend_name] = self._api.backend( + backend_name + ).configuration() + return self._configuration_registry[backend_name].copy() def backend_status(self, backend_name: str) -> Dict[str, Any]: """Return the status of the IBM backend. diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index fc69fb67e..ca525d204 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -23,10 +23,6 @@ from qiskit.providers.backend import BackendV2 as Backend from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit.providers.providerutils import filter_backends -from qiskit.providers.models import ( - PulseBackendConfiguration, - QasmBackendConfiguration, -) from qiskit_ibm_runtime import ibm_backend from .proxies import ProxyConfiguration @@ -47,7 +43,7 @@ from .utils.result_decoder import ResultDecoder from .runtime_job import RuntimeJob from .runtime_job_v2 import RuntimeJobV2 -from .utils import RuntimeDecoder, RuntimeEncoder, to_python_identifier +from .utils import RuntimeDecoder, RuntimeEncoder from .api.client_parameters import ClientParameters from .runtime_options import RuntimeOptions from .ibm_backend import IBMBackend @@ -146,16 +142,12 @@ def __init__( self._channel_strategy = channel_strategy or self._account.channel_strategy self._channel = self._account.channel - self._backends: Dict[str, "ibm_backend.IBMBackend"] = {} - self._backend_configs: Dict[str, Any] = {} + self._backend_allowed_list: List[str] = [] if self._channel == "ibm_cloud": self._api_client = RuntimeClient(self._client_params) - # TODO: We can make the backend discovery lazy - self._backends = self._discover_cloud_backends() - QiskitRuntimeService.global_service = self + self._backend_allowed_list = self._discover_cloud_backends() self._validate_channel_strategy() - return else: auth_client = self._authenticate_ibm_quantum_account(self._client_params) # Update client parameters to use authenticated values. @@ -170,21 +162,15 @@ def __init__( "Please check if the service is in maintenance mode " "https://docs.quantum.ibm.com/announcements/service-alerts." ) - - for hgp in self._hgps.values(): - for backend_name in hgp.backends: - if backend_name not in self._backends: - self._backends[backend_name] = None + self._backend_allowed_list = sorted( + set(sum([hgp.backends for hgp in self._hgps.values()], [])) + ) self._current_instance = self._account.instance if not self._current_instance: self._current_instance = self._get_hgp().name logger.info("Default instance: %s", self._current_instance) QiskitRuntimeService.global_service = self - # TODO - it'd be nice to allow some kind of autocomplete, but `service.ibmq_foo` - # just seems wrong since backends are not runtime service instances. - # self._discover_backends() - def _discover_account( self, token: Optional[str] = None, @@ -289,27 +275,13 @@ def _validate_channel_strategy(self) -> None: "To use this instance, set channel_strategy='q-ctrl'." ) - def _discover_cloud_backends(self) -> Dict[str, "ibm_backend.IBMBackend"]: + def _discover_cloud_backends(self) -> List[str]: """Return the remote backends available for this service instance. Returns: - A dict of the remote backend instances, keyed by backend name. + A list of the remote backend names. """ - ret = OrderedDict() # type: ignore[var-annotated] - backends_list = self._api_client.list_backends(channel_strategy=self._channel_strategy) - for backend_name in backends_list: - raw_config = self._api_client.backend_configuration(backend_name=backend_name) - config = configuration_from_server_data( - raw_config=raw_config, instance=self._account.instance - ) - if not config: - continue - ret[config.backend_name] = ibm_backend.IBMBackend( - configuration=config, - service=self, - api_client=self._api_client, - ) - return ret + return self._api_client.list_backends(channel_strategy=self._channel_strategy) def _resolve_crn(self, account: Account) -> None: account.resolve_crn() @@ -477,15 +449,6 @@ def _get_hgp( raise QiskitBackendNotFoundError(error_message) - def _discover_backends(self) -> None: - """Discovers the remote backends for this account, if not already known.""" - for backend in self._backends.values(): - backend_name = to_python_identifier(backend.name) - # Append _ if duplicate - while backend_name in self.__dict__: - backend_name += "_" - setattr(self, backend_name, backend) - # pylint: disable=arguments-differ def backends( self, @@ -551,49 +514,34 @@ def backends( "Currently fractional_gates and dynamic_circuits feature cannot be " "simulutaneously enabled. Consider disabling one or the other." ) + backends: List[IBMBackend] = [] - instance_filter = instance if instance else self._account.instance if self._channel == "ibm_quantum": + instance_filter = instance if instance else self._account.instance if name: - if name not in self._backends: + if name not in self._backend_allowed_list: raise QiskitBackendNotFoundError("No backend matches the criteria.") - if not self._backends[name] or instance_filter != self._backends[name]._instance: - self._set_backend_config(name) - self._backends[name] = self._create_backend_obj( - self._backend_configs[name], - instance_filter, - ) - if self._backends[name]: - backends.append(self._backends[name]) + backend_names = [name] + hgp = instance_filter elif instance_filter: - hgp = self._get_hgp(instance=instance_filter) - for backend_name in hgp.backends: - if ( - not self._backends[backend_name] - or instance_filter != self._backends[backend_name]._instance - ): - self._set_backend_config(backend_name, instance_filter) - self._backends[backend_name] = self._create_backend_obj( - self._backend_configs[backend_name], instance_filter - ) - if self._backends[backend_name]: - backends.append(self._backends[backend_name]) + backend_names = self._get_hgp(instance=instance_filter).backends + hgp = instance_filter else: - for backend_name, backend_config in self._backends.items(): - if not backend_config: - self._set_backend_config(backend_name) - self._backends[backend_name] = self._create_backend_obj( - self._backend_configs[backend_name] - ) - if self._backends[backend_name]: - backends.append(self._backends[backend_name]) - + backend_names = self._backend_allowed_list + hgp = None + for backend_name in backend_names: + if backend := self._create_backend_obj(backend_name, instance=hgp): + backends.append(backend) else: if instance: raise IBMInputValueError( "The 'instance' keyword is only supported for ``ibm_quantum`` runtime." ) - backends = list(self._backends.values()) + for backend_name in self._backend_allowed_list: + if backend := self._create_backend_obj( + backend_name, instance=self._account.instance + ): + backends.append(backend) if name: kwargs["backend_name"] = name @@ -601,7 +549,6 @@ def backends( backends = list( filter(lambda b: b.configuration().n_qubits >= min_num_qubits, backends) ) - if dynamic_circuits is not None: backends = list( filter( @@ -616,58 +563,60 @@ def backends( backend.options.use_fractional_gates = use_fractional_gates return filter_backends(backends, filters=filters, **kwargs) - def _set_backend_config(self, backend_name: str, instance: Optional[str] = None) -> None: - """Retrieve backend configuration and add to backend_configs. - Args: - backend_name: backend name that will be returned. - instance: the current h/g/p. - """ - if backend_name not in self._backend_configs: - raw_config = self._api_client.backend_configuration(backend_name) - config = configuration_from_server_data(raw_config=raw_config, instance=instance) - self._backend_configs[backend_name] = config - def _create_backend_obj( self, - config: Union[QasmBackendConfiguration, PulseBackendConfiguration], + backend_name: str, instance: Optional[str] = None, ) -> IBMBackend: """Given a backend configuration return the backend object. + Args: - config: backend configuration. + backend_name: Name of backend to instantiate. instance: the current h/g/p. + Returns: A backend object. + Raises: QiskitBackendNotFoundError: if the backend is not in the hgp passed in. """ - if config: - if not instance: - for hgp in list(self._hgps.values()): - if config.backend_name in hgp.backends: - instance = to_instance_format(hgp._hub, hgp._group, hgp._project) - break - - elif config.backend_name not in self._get_hgp(instance=instance).backends: - hgps_with_backend = [] - for hgp in list(self._hgps.values()): - if config.backend_name in hgp.backends: - hgps_with_backend.append( - to_instance_format(hgp._hub, hgp._group, hgp._project) - ) - raise QiskitBackendNotFoundError( - f"Backend {config.backend_name} is not in " - f"{instance}. Please try a different instance. " - f"{config.backend_name} is in the following instances you have access to: " - f"{hgps_with_backend}" + if config := configuration_from_server_data( + raw_config=self._api_client.backend_configuration(backend_name), + instance=instance, + ): + if self._channel == "ibm_quantum": + if not instance: + for hgp in list(self._hgps.values()): + if config.backend_name in hgp.backends: + instance = to_instance_format(hgp._hub, hgp._group, hgp._project) + break + + elif config.backend_name not in self._get_hgp(instance=instance).backends: + hgps_with_backend = [] + for hgp in list(self._hgps.values()): + if config.backend_name in hgp.backends: + hgps_with_backend.append( + to_instance_format(hgp._hub, hgp._group, hgp._project) + ) + raise QiskitBackendNotFoundError( + f"Backend {config.backend_name} is not in " + f"{instance}. Please try a different instance. " + f"{config.backend_name} is in the following instances you have access to: " + f"{hgps_with_backend}" + ) + return ibm_backend.IBMBackend( + instance=instance, + configuration=config, + service=self, + api_client=self._api_client, + ) + else: + # cloud backend doesn't set hgp instance + return ibm_backend.IBMBackend( + configuration=config, + service=self, + api_client=self._api_client, ) - - return ibm_backend.IBMBackend( - instance=instance, - configuration=config, - service=self, - api_client=self._api_client, - ) return None def active_account(self) -> Optional[Dict[str, str]]: @@ -893,17 +842,21 @@ def run( qrt_options.validate(channel=self.channel) - hgp_name = None if self._channel == "ibm_quantum": # Find the right hgp - hgp = self._get_hgp( + hgp_name = self._get_hgp( instance=qrt_options.instance, backend_name=qrt_options.get_backend_name() - ) - hgp_name = hgp.name + ).name if hgp_name != self._current_instance: self._current_instance = hgp_name logger.info("Instance selected: %s", self._current_instance) - backend = self.backend(name=qrt_options.get_backend_name(), instance=hgp_name) + else: + hgp_name = None + backend = qrt_options.backend + if isinstance(backend, str) or ( + hgp_name and isinstance(backend, IBMBackend) and backend._instance != hgp_name + ): + backend = self.backend(name=qrt_options.get_backend_name(), instance=hgp_name) status = backend.status() if status.operational is True and status.status_msg != "active": warnings.warn( @@ -938,11 +891,9 @@ def run( if ex.status_code == 404: raise RuntimeProgramNotFound(f"Program not found: {ex.message}") from None raise IBMRuntimeError(f"Failed to run program: {ex}") from None - backend = ( - self.backend(name=response["backend"], instance=hgp_name) - if response["backend"] - else qrt_options.get_backend_name() - ) + + if response["backend"] and response["backend"] != qrt_options.get_backend_name(): + backend = self.backend(name=response["backend"], instance=hgp_name) if version == 2: job = RuntimeJobV2( diff --git a/release-notes/unreleased/1732.upgrade.rst b/release-notes/unreleased/1732.upgrade.rst new file mode 100644 index 000000000..f4edf5f90 --- /dev/null +++ b/release-notes/unreleased/1732.upgrade.rst @@ -0,0 +1,5 @@ +The :meth:`.QiskitRuntimeService.backends` now always returns +new :class:`IBMBackend` instance even for the same query. +The backend properties and defaults data are retrieved from the server +for every instance when they are accessed for the first time, +while the configuration data is cached internally in the service instance. diff --git a/test/unit/mock/fake_runtime_client.py b/test/unit/mock/fake_runtime_client.py index 55daad112..854000a8d 100644 --- a/test/unit/mock/fake_runtime_client.py +++ b/test/unit/mock/fake_runtime_client.py @@ -441,7 +441,9 @@ def list_backends( def backend_configuration(self, backend_name: str) -> Dict[str, Any]: """Return the configuration a backend.""" - return self._find_backend(backend_name).configuration + if ret := self._find_backend(backend_name).configuration: + return ret.copy() + return None def backend_status(self, backend_name: str) -> Dict[str, Any]: """Return the status of a backend.""" @@ -451,11 +453,15 @@ def backend_properties(self, backend_name: str, datetime: Any = None) -> Dict[st """Return the properties of a backend.""" if datetime: raise NotImplementedError("'datetime' is not supported.") - return self._find_backend(backend_name).properties + if ret := self._find_backend(backend_name).properties: + return ret.copy() + return None def backend_pulse_defaults(self, backend_name: str) -> Dict[str, Any]: """Return the pulse defaults of a backend.""" - return self._find_backend(backend_name).defaults + if ret := self._find_backend(backend_name).defaults: + return ret.copy() + return None # pylint: disable=unused-argument def create_session( diff --git a/test/unit/test_backend_retrieval.py b/test/unit/test_backend_retrieval.py index ba505e91c..4c0d50c91 100644 --- a/test/unit/test_backend_retrieval.py +++ b/test/unit/test_backend_retrieval.py @@ -296,3 +296,23 @@ def test_get_backend_with_fractional_optin(self, use_fractional): "while_loop" in test_backend.target.operation_names, not use_fractional, ) + + def test_backend_with_and_without_fractional_from_same_service(self): + """Test getting backend with and without fractional gates from the same service. + + Backend with and without opt-in must be different object. + """ + service = FakeRuntimeService( + channel="ibm_quantum", + token="my_token", + backend_specs=[FakeApiBackendSpecs(backend_name="FakeFractionalBackend")], + ) + + backend_with_fg = service.backend("fake_fractional", use_fractional_gates=True) + self.assertIn("rx", backend_with_fg.target) + + backend_without_fg = service.backend("fake_fractional", use_fractional_gates=False) + self.assertNotIn("rx", backend_without_fg.target) + self.assertIn("rx", backend_with_fg.target) + + self.assertIsNot(backend_with_fg, backend_without_fg)