diff --git a/qiskit_alice_bob_provider/remote/api/jobs.py b/qiskit_alice_bob_provider/remote/api/jobs.py index adf428a..9351577 100644 --- a/qiskit_alice_bob_provider/remote/api/jobs.py +++ b/qiskit_alice_bob_provider/remote/api/jobs.py @@ -54,6 +54,19 @@ def get_job(client: ApiClient, job_id: str) -> dict: return client.get(f'v1/jobs/{job_id}').json() +def get_job_metrics(client: ApiClient, job_id: str) -> dict: + """Get exposed metrics about a job in the Alice & Bob API + + Args: + client (ApiClient): a client for the Alice & Bob API + job_id (str): the ID of the job in the Alice & Bob API + + Returns: + dict: the API response object, containing the recorded metrics. + """ + return client.get(f'v1/jobs/{job_id}/metrics').json() + + def cancel_job(client: ApiClient, job_id: str) -> None: """Cancel a job in the Alice & Bob API diff --git a/qiskit_alice_bob_provider/remote/backend.py b/qiskit_alice_bob_provider/remote/backend.py index fdc2a4b..9da09cb 100644 --- a/qiskit_alice_bob_provider/remote/backend.py +++ b/qiskit_alice_bob_provider/remote/backend.py @@ -47,6 +47,7 @@ def __init__(self, api_client: ApiClient, target_description: Dict): self._target = ab_target_to_qiskit_target(target_description) self._options = _options_from_ab_target(target_description) self._translation_plugin = _determine_translation_plugin(self._target) + self._verbose = True def __repr__(self) -> str: return f'' @@ -69,7 +70,10 @@ def get_translation_stage_plugin(self): This function is called before we start the circuit translation, and we therefore also use it to inform that we are starting this step. """ - write_current_line('Translating circuit to supported operations...') + if self._verbose: + write_current_line( + 'Translating circuit to supported operations...' + ) return self._translation_plugin def update_options(self, option_updates: Dict[str, Any]) -> Options: @@ -96,7 +100,8 @@ def run(self, run_input: QuantumCircuit, **kwargs) -> AliceBobRemoteJob: Wait for the results by calling :func:`AliceBobRemoteJob.result`. """ - write_current_line('Sending circuit to the API...') + if self._verbose: + write_current_line('Sending circuit to the API...') options = self.update_options(kwargs) input_params = _ab_input_params_from_options(options) job = jobs.create_job(self._api_client, self.name, input_params) @@ -109,6 +114,7 @@ def run(self, run_input: QuantumCircuit, **kwargs) -> AliceBobRemoteJob: api_client=self._api_client, job_id=job['id'], circuit=run_input, + verbose=self._verbose, ) diff --git a/qiskit_alice_bob_provider/remote/job.py b/qiskit_alice_bob_provider/remote/job.py index 7583048..46c1b79 100644 --- a/qiskit_alice_bob_provider/remote/job.py +++ b/qiskit_alice_bob_provider/remote/job.py @@ -18,7 +18,7 @@ import time from dataclasses import dataclass from io import StringIO -from typing import Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional from qiskit import QuantumCircuit from qiskit.providers import JobStatus, JobV1 @@ -53,12 +53,14 @@ class _DownloadedFile: class AliceBobRemoteJob(JobV1): """A Qiskit job referencing a job executed in the Alice & Bob API""" + # pylint: disable=too-many-arguments def __init__( self, backend: BackendV2, api_client: ApiClient, job_id: str, circuit: QuantumCircuit, + verbose: bool, ): """A job should not be instantiated manually but created by calling :func:`AliceBobRemoteBackend.run` or :func:`qiskit.execute`. @@ -75,11 +77,13 @@ def __init__( self._backend_v2 = backend self._api_client = api_client self._circuit = circuit + self._verbose = verbose self._last_response: Optional[Dict] = None self._ab_status: Optional[AliceBobEventType] = None self._status: Optional[JobStatus] = None self._counts: Optional[Dict[str, int]] = None self._files: Dict[str, _DownloadedFile] = {} + self._metrics: Dict[str, Any] = {} def _refresh(self) -> None: """If the job status is not final, refresh the description of the API @@ -151,31 +155,43 @@ def _get_counts(self) -> Dict[str, int]: self._counts[hex(int(row['memory'], 2))] = int(row['count']) return self._counts + def _get_metrics(self) -> Dict[str, Any]: + self._metrics = jobs.get_job_metrics(self._api_client, self.job_id()) + return self._metrics + def _monitor_state( self, timeout: Optional[float] = None, wait: float = 2 ) -> None: start_time = time.time() status = self.status() - # TODO: add timestamp while status not in JOB_FINAL_STATES: - if self._ab_status == AliceBobEventType.INPUT_READY: + if ( + self._verbose + and self._ab_status == AliceBobEventType.INPUT_READY + ): write_current_line( - f'\rJob {self.job_id()} is waiting to be compiled.' + f'Job {self.job_id()} is waiting to be compiled.' ) - if self._ab_status in { + if self._verbose and self._ab_status in { AliceBobEventType.COMPILING, AliceBobEventType.TRANSPILING, }: # We take the shortcut of assuming compilation + transpilation # are the same things at the moment. - write_current_line(f'\rJob {self.job_id()} is being compiled.') - if self._ab_status == AliceBobEventType.TRANSPILED: + write_current_line(f'Job {self.job_id()} is being compiled.') + if ( + self._verbose + and self._ab_status == AliceBobEventType.TRANSPILED + ): write_current_line( - f'\rJob {self.job_id()} is waiting to be executed.' + f'Job {self.job_id()} is waiting to be executed.' ) - if self._ab_status == AliceBobEventType.EXECUTING: - write_current_line(f'\rJob {self.job_id()} is being executed.') + if ( + self._verbose + and self._ab_status == AliceBobEventType.EXECUTING + ): + write_current_line(f'Job {self.job_id()} is being executed.') elapsed_time = time.time() - start_time if timeout is not None and elapsed_time >= timeout: @@ -186,8 +202,25 @@ def _monitor_state( time.sleep(wait) status = self.status() - if self._ab_status == AliceBobEventType.SUCCEEDED: - write_current_line(f'\rJob {self.job_id()} finished successfully.') + if self._verbose and self._ab_status == AliceBobEventType.SUCCEEDED: + metrics = self._get_metrics() + success_msg = f'Job {self.job_id()} finished successfully.' + if 'qpu_duration_ns' in metrics and isinstance( + metrics['qpu_duration_ns'], int + ): + success_msg += ( + ' Time spent executing on QPU: ' + f'{_format_nanoseconds(metrics["qpu_duration_ns"])}.' + ) + elif 'simulation_duration_ns' in metrics and isinstance( + metrics['simulation_duration_ns'], int + ): + success_msg += ( + ' Time spent simulating: ' + f'{_format_nanoseconds(metrics["simulation_duration_ns"])}' + '.' + ) + write_current_line(success_msg) # For the other final states, Qiskit is already displaying the error # with the relevant details. @@ -236,3 +269,21 @@ def status(self) -> JobStatus: """Return the status of the job, among the values of ``JobStatus``.""" self._refresh() return self._status + + +def _format_nanoseconds(time_ns: int) -> str: + """Format a given number of nanoseconds to a string containing a unit + and the time value converted to this unit in the most readable way. + This will convert the nanoseconds either to microseconds, milliseconds, + seconds or minutes.""" + if time_ns < 1e3: # < 1us + return f'{time_ns}ns' + elif time_ns < 1e6: # < 1ms + return f'{(time_ns / 1e3):.2f}us' + elif time_ns < 1e9: # < 1s + return f'{(time_ns / 1e6):.2f}ms' + elif time_ns < 60e9: # < 1min + return f'{(time_ns / 1e9):.2f}s' + else: # min & seconds format + minutes, seconds = divmod(time_ns / 1e9, 60) + return f'{minutes}min {seconds:.0f}s' diff --git a/qiskit_alice_bob_provider/remote/provider.py b/qiskit_alice_bob_provider/remote/provider.py index bca015b..0af1f3c 100644 --- a/qiskit_alice_bob_provider/remote/provider.py +++ b/qiskit_alice_bob_provider/remote/provider.py @@ -52,12 +52,30 @@ def __init__( for ab_target in list_targets(client): self._backends.append(AliceBobRemoteBackend(client, ab_target)) - def get_backend(self, name=None, **kwargs) -> AliceBobRemoteBackend: + def get_backend( + self, name=None, verbose=True, **kwargs + ) -> AliceBobRemoteBackend: + """Return a single backend matching by its name. + + Args: + name (str): name of the backend + verbose (bool): if True, will display information about the jobs + execution. + Defaults to True. + **kwargs: additional backend parameters + (see targets documentation). + + Returns: + AliceBobRemoteBackend: backend matching the given name. + """ backend = super().get_backend(name) # We allow to set the options when getting the backend, # to align with what we do in the local provider. if kwargs: backend.update_options(kwargs) + + # pylint: disable=protected-access + backend._verbose = verbose return backend def backends( diff --git a/qiskit_alice_bob_provider/remote/utils.py b/qiskit_alice_bob_provider/remote/utils.py index fa8451d..ef3d70b 100644 --- a/qiskit_alice_bob_provider/remote/utils.py +++ b/qiskit_alice_bob_provider/remote/utils.py @@ -1,6 +1,13 @@ +from datetime import datetime + + def write_current_line(text: str) -> None: """Write a given text on the same line as the previous call of this function.""" # We use a padding of 80 characters to be sure everything is flushed out # when we do the next call to this function. - print(f'{text:<80}', end='\r', flush=True) + print( + f'[{datetime.now().strftime("%H:%M:%S")}] {text:<80}', + end='\r', + flush=True, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0285ef2..089d6ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -376,6 +376,11 @@ def successful_job(mocked_targets: Mocker) -> Mocker: f'/v1/jobs/{job_id}/output', text='11,12\n10,474\n01,6\n00,508\n', ) + mocked_targets.register_uri( + 'GET', + f'/v1/jobs/{job_id}/metrics', + json={}, + ) return mocked_targets @@ -500,6 +505,11 @@ def failed_transpilation_job(mocked_targets: Mocker) -> Mocker: } }, ) + mocked_targets.register_uri( + 'GET', + f'/v1/jobs/{job_id}/metrics', + json={}, + ) return mocked_targets @@ -620,6 +630,11 @@ def failed_execution_job(mocked_targets: Mocker) -> Mocker: } }, ) + mocked_targets.register_uri( + 'GET', + f'/v1/jobs/{job_id}/metrics', + json={}, + ) return mocked_targets @@ -767,6 +782,11 @@ def cancellable_job(mocked_targets: Mocker) -> Mocker: } }, ) + mocked_targets.register_uri( + 'GET', + f'/v1/jobs/{job_id}/metrics', + json={}, + ) return mocked_targets @@ -857,4 +877,9 @@ def failed_validation_job(mocked_targets: Mocker) -> Mocker: } }, ) + mocked_targets.register_uri( + 'GET', + f'/v1/jobs/{job_id}/metrics', + json={}, + ) return mocked_targets