"""Common interface for gate sequence execution.
Many experimental protocols consist of acquiring outcome statistics for a number of gate
sequences (for instance those implemented in :mod:`oitg.protocols`). Traditionally, a
considerable fraction of the time spent on implementing these protocols in one's
experiment comes from having to write code to convert between different data formats
for specifying gate sequences and results, fetching result data from result files, etc.
This module addresses this by specifying a common interface to sequence runners, that
is, code that acquires outcome data for a list of given gate sequences.
This interface is given by the :class:`SequenceRunner` abstract base class, and the
:class:`SequenceRunnerOptions` structure, which implementations of the former should use
to accept settings giving the details of how (often) to execute the sequences (in place
of, say, an unwieldly long list of constructor arguments).
There are two main aspects of acquiring experimental data that this interface addresses
beyond just running sequentially through a long list of gate strings. The first is the
fact that experiments will tend to run as kernels on an ARTIQ core device. Kernels are
slow to start, and so is, to a lesser degree, any subsequent network communication.
Hence, it is advisable to compile/run more than one gate sequence at once. On the other
hand, core device resources are limited, so trying to e.g. keep DMA recordings for
thousands of long gate sequences in memory at the same time would be a bad idea. The
natural solution to this is to process the total list of gate sequences in **chunks** of
appropriate lengths. The chunk size is configurable, as it depends on the particulars of
the target system (keeping them about 1 s in wall clock duration might be a good
starting point). The exact interpretation will depend on the specifics of the sequence
runner implementation, but a common design would be to have a long-running kernel on the
core device, which fetches sequences from the host in slices of the configured chunk
length, acquires the data for them, and reports the results back to the host afterwards
(while fetching the next chunk).
The second aspect are **repeats** and **randomisation**. Typical experiments would
acquire a few hundred or thousand single-shot measurements per sequence, and might
comprise thousands of total sequences. For instance, a multi-qubit tomography experiment
might run for an hour or more. On these time scales, slow changes in the laboratory
environment, such as thermal drifts (with their typical time scale of ~10 min), are very
noticeable. To avoid any systematic shifts in the resulting data, a useful strategy is
to acquire the data points in random order to wash out any correlations. Randomly
permuting the sequences run shot-by-shot would be ideal, but possibly very expensive, as
mentioned above. A possible compromise is to a) acquire several shots per sequence at a
time, and/or b) choose at random only within each chunk, where precomputed sequences can
be quickly accessed.
The appropriate tradeoffs for both these aspects can be chosen by appropriately
configuring the :class:`SequenceRunnerOptions`: The configurable chunk size allows to
limit the amount of resources needed on the core device (e.g. DMA sequences, size of
result arrays). The three (conceptual) nested loops (global repeats -> [chunking] ->
repeats per chunk -> shots per repeat) allow trading off randomisation quality vs.
performance: If switching between different sequences in the same chunk is cheap, choose
few shots per repeat, but many repeats per chunk. If you are concerned about slow
drifts, use fewer total shots per chunk, but more global repeats.
See e.g. ``oxart.circuits.runner`` for actual sequence runner implementations using
ARTIQ.
"""
from itertools import chain
from numpy import ndarray
from typing import Callable, Iterable, Union
from ..gate import GateGenerator, GateSequence
from ..qasm import gate_to_qasm
# TODO (Python 3.7+): Convert this into a data class.
[docs]
class SequenceRunnerOptions:
r"""Specifies common :class:`SequenceRunner` options.
See the :mod:`module <oitg.circuits.runner>`\ -level docstring for background on
the concepts.
If you do not want to support the whole interface immediately while bringing up a
new runner implementation (e.g. no per-chunk repeats or randomisation), consider
emitting a warning and, in the case of repeats, folding the factor into other
multipliers.
:param num_global_repeats: The number of times to run through the list of sequences
given.
:param randomise_globally: Whether to randomise the order of sequences between
global repeats (so that they are divided into different chunks, etc.).
:param chunk_size: Number of sequences to execute at once (typically in one RPC
cycle to the core device).
:param num_repeats_per_chunk: The number of times to cycle through all sequences
within each chunk.
:param num_shots_per_repeat: The number of shots (single measurements) to acquire
for each sequence per repeat.
:param randomise_per_repeat: Whether to randomise the order of sequences within each
repeat.
"""
def __init__(self,
num_global_repeats: int = 1,
randomise_globally: bool = True,
chunk_size: int = 1,
num_repeats_per_chunk: int = 1,
num_shots_per_repeat: int = 100,
randomise_per_repeat: bool = True):
self.num_global_repeats = num_global_repeats
self.randomise_globally = randomise_globally
self.chunk_size = chunk_size
self.num_repeats_per_chunk = num_repeats_per_chunk
self.num_shots_per_repeat = num_shots_per_repeat
self.randomise_per_repeat = randomise_per_repeat
[docs]
class SequenceRunner:
r"""Executes a number of gate sequences to gather outcome statistics.
See the :mod:`module <oitg.circuits.runner>`\ -level docstring for details.
"""
[docs]
def run_sequences(self,
sequences: Iterable[GateSequence],
num_qubits: Union[None, int] = None,
progress_callback: Callable[[ndarray, ndarray], None] = None,
progress_callback_interval: float = 5.0,
dataset_prefix: Union[None, str] = "data.circuits."):
r"""Runs the given sequences and returns result statistics.
:param sequences: The gate sequences to execute.
:param num_qubits: The number of qubits in the circuit; used for readout. If not
given, this will be inferred from the largest operand index used in the
circuit.
:param progress_callback: An optional callback to be invoked periodically
throughout data acquisition, e.g. for updating some on-line plots/analysis.
Two NumPy arrays are supplied as arguments, giving the sequence indices and
outcomes acquired since the last time the callback was invoked (see return
value).
:param progress_callback_interval: The interval between invocations of the
progress callback, in seconds.
:param dataset_prefix: Prefix for dataset keys to write executed sequences and
their results to. If ``None``, results are not written to datasets. Sequence
runner implementations not embedded into an ARTIQ experiment can ignore
this.
:return: A tuple ``(run_order, outcomes)`` of NumPy arrays. ``run_order`` lists
the order in which the sequences were executed (possibly with repeats) by
their respective index in the passed collection. ``outcomes`` lists the
number of occurrences of the respective measurement outcomes as a
two-dimensional array, where the first index matches ``run_order``, and the
second specifies the outcome in canonical binary order (i.e. corresponding
to projection onto :math:`\left|0\ldots00\right>, \left|0\ldots01\right>,
\ldots, \left|1\ldots11\right>`).
"""
raise NotImplementedError
[docs]
def stringify_gate_sequence(seq: GateGenerator):
"""Return the string used to represent ``seq`` in the result datasets."""
return ";".join(chain.from_iterable(gate_to_qasm(g) for g in seq))