diff --git a/docs/apidocs/execution_span.rst b/docs/apidocs/execution_span.rst new file mode 100644 index 000000000..0091503c4 --- /dev/null +++ b/docs/apidocs/execution_span.rst @@ -0,0 +1,4 @@ +.. automodule:: qiskit_ibm_runtime.execution_span + :no-members: + :no-inherited-members: + :no-special-members: diff --git a/docs/apidocs/index.rst b/docs/apidocs/index.rst index 280a5a7d0..a5bee85d7 100644 --- a/docs/apidocs/index.rst +++ b/docs/apidocs/index.rst @@ -14,3 +14,4 @@ qiskit-ibm-runtime API reference transpiler qiskit_ibm_runtime.transpiler.passes.scheduling fake_provider + execution_span diff --git a/qiskit_ibm_runtime/execution_span.py b/qiskit_ibm_runtime/execution_span.py deleted file mode 100644 index 1598f5f32..000000000 --- a/qiskit_ibm_runtime/execution_span.py +++ /dev/null @@ -1,283 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2024. -# -# 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. - -"""Execution span classes.""" - -from __future__ import annotations - -import abc -from datetime import datetime -import math -from typing import overload, Iterable, Iterator, Tuple - -import numpy as np -import numpy.typing as npt - - -# Python 3.8 does not recognize tuple[ bool: - pass - - def __lt__(self, other: ExecutionSpan) -> bool: - return (self.start, self.stop) < (other.start, other.stop) - - def __repr__(self) -> str: - attrs = [ - f"start='{self.start:%Y-%m-%d %H:%M:%S}'", - f"stop='{self.stop:%Y-%m-%d %H:%M:%S}'", - f"size={self.size}", - ] - return f"{type(self).__name__}(<{', '.join(attrs)}>)" - - @property - def duration(self) -> float: - """The duration of this span, in seconds.""" - return (self.stop - self.start).total_seconds() - - @property - @abc.abstractmethod - def pub_idxs(self) -> list[int]: - """Which pubs, by index, have dependence on this execution span.""" - - @property - def start(self) -> datetime: - """The start time of the span, in UTC.""" - return self._start - - @property - def stop(self) -> datetime: - """The stop time of the span, in UTC.""" - return self._stop - - @property - def size(self) -> int: - """The total number of results with dependence on this execution span, across all pubs. - - This attribute is equivalent to the sum of the elements of all present :meth:`mask`\\s. - For sampler results, it represents the total number of shots with dependence on this - execution span. - - Combine this attribute with :meth:`filter_by_pub` to find the size of some particular pub: - - .. code:: python - - span.filter_by_pub(2).size - - """ - return sum(self.mask(pub_idx).sum() for pub_idx in self.pub_idxs) - - @abc.abstractmethod - def mask(self, pub_idx: int) -> npt.NDArray[np.bool_]: - """Return an array-valued mask specifying which parts of a pub result depend on this span. - - Args: - pub_idx: The index of the pub to return a mask for. - - Returns: - An array with the same shape as the pub data. - """ - - def contains_pub(self, pub_idx: int | Iterable[int]) -> bool: - """Return whether the pub with the given index has data with dependence on this span. - - Args: - pub_idx: One or more pub indices from the original primitive call. - - Returns: - Whether there is dependence on this span. - """ - pub_idx = {pub_idx} if isinstance(pub_idx, int) else set(pub_idx) - return not pub_idx.isdisjoint(self.pub_idxs) - - @abc.abstractmethod - def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "ExecutionSpan": - """Return a new span whose slices are filtered to the provided pub indices. - - For example, if this span contains slice information for pubs with indices 1, 3, 4 and - ``[1, 4]`` is provided, then the span returned by this method will contain slice information - for only those two indices, but be identical otherwise. - - Args: - pub_idx: One or more pub indices from the original primitive call. - - Returns: - A new filtered span. - """ - - -class SliceSpan(ExecutionSpan): - """An :class:`~.ExecutionSpan` for data stored in a sliceable format. - - This type of execution span references pub result data by assuming that it is a sliceable - portion of the (row major) flattened data. Therefore, for each pub dependent on this span, the - constructor accepts a single :class:`slice` object, along with the corresponding shape of the - data to be sliced. - - Args: - start: The start time of the span, in UTC. - stop: The stop time of the span, in UTC. - data_slices: A map from pub indices to pairs ``(shape_tuple, slice)``. - """ - - def __init__( - self, start: datetime, stop: datetime, data_slices: dict[int, tuple[ShapeType, slice]] - ): - super().__init__(start, stop) - self._data_slices = data_slices - - def __eq__(self, other: object) -> bool: - return isinstance(other, SliceSpan) and ( - self.start == other.start - and self.stop == other.stop - and self._data_slices == other._data_slices - ) - - @property - def pub_idxs(self) -> list[int]: - return sorted(self._data_slices) - - @property - def size(self) -> int: - size = 0 - for shape, sl in self._data_slices.values(): - size += len(range(math.prod(shape))[sl]) - return size - - def mask(self, pub_idx: int) -> npt.NDArray[np.bool_]: - shape, sl = self._data_slices[pub_idx] - mask = np.zeros(shape, dtype=np.bool_) - mask.ravel()[sl] = True - return mask - - def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "SliceSpan": - pub_idx = {pub_idx} if isinstance(pub_idx, int) else set(pub_idx) - slices = {idx: val for idx, val in self._data_slices.items() if idx in pub_idx} - return SliceSpan(self.start, self.stop, slices) - - -class ExecutionSpans: - """A collection of timings for pub results. - - This class is a list-like containing :class:`~.ExecutionSpan`\\s, where each execution span - represents a time window of data collection, and contains a reference to exactly which of the - data were collected during the window. - - .. code::python - - spans = sampler_job.result().metadata["execution"]["execution_spans"] - - for span in spans: - print(span) - - It is possible for distinct time windows to overlap. This is not because a QPU was performing - multiple executions at once, but is instead an artifact of certain classical processing - that may happen concurrently with quantum execution. The guarantee being made is that the - referenced data definitely occurred in the reported execution span, but not necessarily that - the limits of the time window are as tight as possible. - """ - - def __init__(self, spans: Iterable[ExecutionSpan]): - self._spans = list(spans) - - def __len__(self) -> int: - return len(self._spans) - - @overload - def __getitem__(self, idxs: int) -> ExecutionSpan: ... - - @overload - def __getitem__(self, idxs: slice | list[int]) -> "ExecutionSpans": ... - - def __getitem__(self, idxs: int | slice | list[int]) -> ExecutionSpan | "ExecutionSpans": - if isinstance(idxs, int): - return self._spans[idxs] - if isinstance(idxs, slice): - return ExecutionSpans(self._spans[idxs]) - return ExecutionSpans(self._spans[idx] for idx in idxs) - - def __iter__(self) -> Iterator[ExecutionSpan]: - return iter(self._spans) - - def __repr__(self) -> str: - return f"ExecutionSpans({repr(self._spans)})" - - def __eq__(self, other: object) -> bool: - return isinstance(other, ExecutionSpans) and self._spans == other._spans - - @property - def pub_idxs(self) -> list[int]: - """Which pubs, by index, have dependence on one or more execution spans present.""" - return sorted({idx for span in self for idx in span.pub_idxs}) - - @property - def start(self) -> datetime: - """The start time of the entire collection, in UTC.""" - return min(span.start for span in self) - - @property - def stop(self) -> datetime: - """The stop time of the entire collection, in UTC.""" - return max(span.stop for span in self) - - @property - def duration(self) -> float: - """The total duration of this collection, in seconds.""" - return (self.stop - self.start).total_seconds() - - def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "ExecutionSpans": - """Return a new set of spans where each one has been filtered to the specified pubs. - - See also :meth:~.ExecutionSpan.filter_by_pub`. - - Args: - pub_idx: One or more pub indices to filter. - """ - return ExecutionSpans(span.filter_by_pub(pub_idx) for span in self) - - def sort(self, inplace: bool = True) -> "ExecutionSpans": - """Return the same execution spans, sorted. - - Sorting is done by the :attr:`~.ExecutionSpan.start` timestamp of each execution span. - - Args: - inplace: Whether to sort this instance in place, or return a copy. - - Returns: - This instance if ``inplace``, a new instance otherwise, sorted. - """ - obj = self if inplace else ExecutionSpans(self) - obj._spans.sort() - return obj diff --git a/qiskit_ibm_runtime/execution_span/__init__.py b/qiskit_ibm_runtime/execution_span/__init__.py new file mode 100644 index 000000000..2e747ea45 --- /dev/null +++ b/qiskit_ibm_runtime/execution_span/__init__.py @@ -0,0 +1,41 @@ +# 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. + +""" +========================================================== +Execution Spans (:mod:`qiskit_ibm_runtime.execution_span`) +========================================================== + +.. currentmodule:: qiskit_ibm_runtime.execution_span + +Overview +======== + +An :class:`~.ExecutionSpans` class instance is an iterable of :class:`~.ExecutionSpan`\\s, where +each iterand gives timing information about a chunk of data. Execution spans are returned as part +of the metadata of a primitive job result. + +Classes +======= + +.. autosummary:: + :toctree: ../stubs/ + + ExecutionSpan + ExecutionSpans + ShapeType + SliceSpan +""" + +from .execution_span import ExecutionSpan, ShapeType +from .execution_spans import ExecutionSpans +from .slice_span import SliceSpan diff --git a/qiskit_ibm_runtime/execution_span/execution_span.py b/qiskit_ibm_runtime/execution_span/execution_span.py new file mode 100644 index 000000000..cca56d913 --- /dev/null +++ b/qiskit_ibm_runtime/execution_span/execution_span.py @@ -0,0 +1,137 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. + +"""ExecutionSpan""" + +from __future__ import annotations + +import abc +from datetime import datetime +from typing import Iterable, Tuple + +import numpy as np +import numpy.typing as npt + + +# Python 3.8 does not recognize tuple[ bool: + pass + + def __lt__(self, other: ExecutionSpan) -> bool: + return (self.start, self.stop) < (other.start, other.stop) + + def __repr__(self) -> str: + attrs = [ + f"start='{self.start:%Y-%m-%d %H:%M:%S}'", + f"stop='{self.stop:%Y-%m-%d %H:%M:%S}'", + f"size={self.size}", + ] + return f"{type(self).__name__}(<{', '.join(attrs)}>)" + + @property + def duration(self) -> float: + """The duration of this span, in seconds.""" + return (self.stop - self.start).total_seconds() + + @property + @abc.abstractmethod + def pub_idxs(self) -> list[int]: + """Which pubs, by index, have dependence on this execution span.""" + + @property + def start(self) -> datetime: + """The start time of the span, in UTC.""" + return self._start + + @property + def stop(self) -> datetime: + """The stop time of the span, in UTC.""" + return self._stop + + @property + def size(self) -> int: + """The total number of results with dependence on this execution span, across all pubs. + + This attribute is equivalent to the sum of the elements of all present :meth:`mask`\\s. + For sampler results, it represents the total number of shots with dependence on this + execution span. + + Combine this attribute with :meth:`filter_by_pub` to find the size of some particular pub: + + .. code:: python + + span.filter_by_pub(2).size + + """ + return sum(self.mask(pub_idx).sum() for pub_idx in self.pub_idxs) + + @abc.abstractmethod + def mask(self, pub_idx: int) -> npt.NDArray[np.bool_]: + """Return an array-valued mask specifying which parts of a pub result depend on this span. + + Args: + pub_idx: The index of the pub to return a mask for. + + Returns: + An array with the same shape as the pub data. + """ + + def contains_pub(self, pub_idx: int | Iterable[int]) -> bool: + """Return whether the pub with the given index has data with dependence on this span. + + Args: + pub_idx: One or more pub indices from the original primitive call. + + Returns: + Whether there is dependence on this span. + """ + pub_idx = {pub_idx} if isinstance(pub_idx, int) else set(pub_idx) + return not pub_idx.isdisjoint(self.pub_idxs) + + @abc.abstractmethod + def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "ExecutionSpan": + """Return a new span whose slices are filtered to the provided pub indices. + + For example, if this span contains slice information for pubs with indices 1, 3, 4 and + ``[1, 4]`` is provided, then the span returned by this method will contain slice information + for only those two indices, but be identical otherwise. + + Args: + pub_idx: One or more pub indices from the original primitive call. + + Returns: + A new filtered span. + """ diff --git a/qiskit_ibm_runtime/execution_span/execution_spans.py b/qiskit_ibm_runtime/execution_span/execution_spans.py new file mode 100644 index 000000000..892e28fbe --- /dev/null +++ b/qiskit_ibm_runtime/execution_span/execution_spans.py @@ -0,0 +1,115 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. + +"""ExecutionSpans""" + +from __future__ import annotations + +from datetime import datetime +from typing import overload, Iterable, Iterator + +from .execution_span import ExecutionSpan + + +class ExecutionSpans: + """A collection of timings for pub results. + + This class is a list-like containing :class:`~.ExecutionSpan`\\s, where each execution span + represents a time window of data collection, and contains a reference to exactly which of the + data were collected during the window. + + .. code::python + + spans = sampler_job.result().metadata["execution"]["execution_spans"] + + for span in spans: + print(span) + + It is possible for distinct time windows to overlap. This is not because a QPU was performing + multiple executions at once, but is instead an artifact of certain classical processing + that may happen concurrently with quantum execution. The guarantee being made is that the + referenced data definitely occurred in the reported execution span, but not necessarily that + the limits of the time window are as tight as possible. + """ + + def __init__(self, spans: Iterable[ExecutionSpan]): + self._spans = list(spans) + + def __len__(self) -> int: + return len(self._spans) + + @overload + def __getitem__(self, idxs: int) -> ExecutionSpan: ... + + @overload + def __getitem__(self, idxs: slice | list[int]) -> "ExecutionSpans": ... + + def __getitem__(self, idxs: int | slice | list[int]) -> ExecutionSpan | "ExecutionSpans": + if isinstance(idxs, int): + return self._spans[idxs] + if isinstance(idxs, slice): + return ExecutionSpans(self._spans[idxs]) + return ExecutionSpans(self._spans[idx] for idx in idxs) + + def __iter__(self) -> Iterator[ExecutionSpan]: + return iter(self._spans) + + def __repr__(self) -> str: + return f"ExecutionSpans({repr(self._spans)})" + + def __eq__(self, other: object) -> bool: + return isinstance(other, ExecutionSpans) and self._spans == other._spans + + @property + def pub_idxs(self) -> list[int]: + """Which pubs, by index, have dependence on one or more execution spans present.""" + return sorted({idx for span in self for idx in span.pub_idxs}) + + @property + def start(self) -> datetime: + """The start time of the entire collection, in UTC.""" + return min(span.start for span in self) + + @property + def stop(self) -> datetime: + """The stop time of the entire collection, in UTC.""" + return max(span.stop for span in self) + + @property + def duration(self) -> float: + """The total duration of this collection, in seconds.""" + return (self.stop - self.start).total_seconds() + + def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "ExecutionSpans": + """Return a new set of spans where each one has been filtered to the specified pubs. + + See also :meth:~.ExecutionSpan.filter_by_pub`. + + Args: + pub_idx: One or more pub indices to filter. + """ + return ExecutionSpans(span.filter_by_pub(pub_idx) for span in self) + + def sort(self, inplace: bool = True) -> "ExecutionSpans": + """Return the same execution spans, sorted. + + Sorting is done by the :attr:`~.ExecutionSpan.start` timestamp of each execution span. + + Args: + inplace: Whether to sort this instance in place, or return a copy. + + Returns: + This instance if ``inplace``, a new instance otherwise, sorted. + """ + obj = self if inplace else ExecutionSpans(self) + obj._spans.sort() + return obj diff --git a/qiskit_ibm_runtime/execution_span/slice_span.py b/qiskit_ibm_runtime/execution_span/slice_span.py new file mode 100644 index 000000000..143bf187b --- /dev/null +++ b/qiskit_ibm_runtime/execution_span/slice_span.py @@ -0,0 +1,74 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2024. +# +# 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. + +"""SliceSpan""" + +from __future__ import annotations + +from datetime import datetime +import math +from typing import Iterable + +import numpy as np +import numpy.typing as npt + +from .execution_span import ExecutionSpan, ShapeType + + +class SliceSpan(ExecutionSpan): + """An :class:`~.ExecutionSpan` for data stored in a sliceable format. + + This type of execution span references pub result data by assuming that it is a sliceable + portion of the (row major) flattened data. Therefore, for each pub dependent on this span, the + constructor accepts a single :class:`slice` object, along with the corresponding shape of the + data to be sliced. + + Args: + start: The start time of the span, in UTC. + stop: The stop time of the span, in UTC. + data_slices: A map from pub indices to pairs ``(shape_tuple, slice)``. + """ + + def __init__( + self, start: datetime, stop: datetime, data_slices: dict[int, tuple[ShapeType, slice]] + ): + super().__init__(start, stop) + self._data_slices = data_slices + + def __eq__(self, other: object) -> bool: + return isinstance(other, SliceSpan) and ( + self.start == other.start + and self.stop == other.stop + and self._data_slices == other._data_slices + ) + + @property + def pub_idxs(self) -> list[int]: + return sorted(self._data_slices) + + @property + def size(self) -> int: + size = 0 + for shape, sl in self._data_slices.values(): + size += len(range(math.prod(shape))[sl]) + return size + + def mask(self, pub_idx: int) -> npt.NDArray[np.bool_]: + shape, sl = self._data_slices[pub_idx] + mask = np.zeros(shape, dtype=np.bool_) + mask.ravel()[sl] = True + return mask + + def filter_by_pub(self, pub_idx: int | Iterable[int]) -> "SliceSpan": + pub_idx = {pub_idx} if isinstance(pub_idx, int) else set(pub_idx) + slices = {idx: val for idx, val in self._data_slices.items() if idx in pub_idx} + return SliceSpan(self.start, self.stop, slices)