Running on Backends¶
The interaction model for quantum computing in the near future is set to follow the co-processor model: there is a main program running on the classical host computer which routinely sends off jobs to a specialist device that can handle that class of computation efficiently, similar to interacting with GPUs and cloud HPC resources. We have already seen how to use pytket
to describe a job to be performed with the Circuit
class; the next step is to look at how we interact with the co-processor to run it.
A Backend
represents a connection to some co-processor instance, which can be either quantum hardware or a simulator. It presents a uniform interface for submitting Circuit
s to be processed and retrieving the results, allowing the general workflow of “generate, compile, process, retrieve, interpret” to be performed with little dependence on the specific co-processor used. This is to promote the development of platform-independent software, helping the code that you write to be more future-proof and encouraging exploration of the ever-changing landscape of quantum hardware solutions.
With the wide variety of Backend
s available, they naturally have very different capabilities and limitations. The class is designed to open up this information so that it is easy to examine at runtime and make hardware-dependent choices as needed for maximum performance, whilst providing a basic abstract model that is easy for proof-of-concept testing.
No Backend
s are currently provided with the core pytket) package, but most extension modules will add simulators or devices from the given provider, such as the AerBackend
and IBMQBackend
with pytket-qiskit or the QuantinuumBackend
with pytket-quantinuum.
Backend Requirements¶
Every device and simulator will have some restrictions to allow for a simpler implementation or because of the limits of engineering or noise within a device. For example, devices and simulators are typically designed to only support a small (but universal) gate set, so a Circuit
containing other gate types could not be run immediately. However, as long as the fragment supported is universal, it is enough to be able to compile down to a semantically-equivalent Circuit
which satisfies the requirements, for example, by translating each unknown gate into sequences of known gates.
Other common restrictions presented by QPUs include the number of available qubits and their connectivity (multi-qubit gates may only be performed between adjacent qubits on the architecture). Measurements may also be noisy or take a long time on some QPUs, leading to the destruction or decoherence of any remaining quantum state, so they are artificially restricted to only happen in a single layer at the end of execution and mid-circuit measurements are rejected. More extremely, some classes of classical simulators will reject measurements entirely as they are designed to simulate pure quantum circuits (for example, when looking to yield a statevector or unitary deterministically).
Each Backend
object is aware of the restrictions of the underlying device or simulator, encoding them as a collection of Predicate
s. Each Predicate
is essentially a Boolean property of a Circuit
which must return True
for the Circuit
to successfully run. The set of Predicate
s required by a Backend
can be queried with required_predicates
.
from pytket.extensions.qiskit import IBMQBackend, AerStateBackend
dev_b = IBMQBackend("ibm_brisbane")
sim_b = AerStateBackend()
print(dev_b.required_predicates)
print(sim_b.required_predicates)
[NoClassicalControlPredicate, NoFastFeedforwardPredicate, NoMidMeasurePredicate, NoSymbolsPredicate, GateSetPredicate:{ U1 noop U2 CX Barrier Measure U3 }, DirectednessPredicate:{ Nodes: 5, Edges: 8 }]
[NoClassicalControlPredicate, NoFastFeedforwardPredicate, GateSetPredicate:{ CU1 CZ CX Unitary2qBox Sdg U1 Unitary1qBox SWAP S U2 CCX Y U3 Z X T noop Tdg Reset H }]
Knowing the requirements of each Backend
is handy in case it has consequences for how you design a Circuit
, but can generally be abstracted away. Calling valid_circuit()
can check whether or not a Circuit
satisfies every requirement to run on the Backend
, and if it is not immediately valid then get_compiled_circuit()
will try to solve all of the remaining constraints when possible (note that restrictions on measurements or conditional gate support may not be fixed by compilation), and return a new Circuit
.
from pytket import Circuit, OpType
from pytket.extensions.qiskit import AerBackend
circ = Circuit(3, 2)
circ.H(0).Ry(0.25, 1)
circ.add_gate(OpType.CnRy, [0.74], [0, 1, 2]) # CnRy not in AerBackend gate set
circ.measure_all()
backend = AerBackend()
print("Circuit valid for AerBackend?", backend.valid_circuit(circ))
compiled_circ = backend.get_compiled_circuit(circ) # Compile circuit to AerBackend
print("Compiled circuit valid for AerBackend?", backend.valid_circuit(compiled_circ))
Circuit valid for AerBackend? False
Compiled circuit valid for AerBackend? True
Now that we can prepare our Circuit
s to be suitable for a given Backend
, we can send them off to be run and examine the results. This is always done by calling process_circuit()
which sends a Circuit
for execution and returns a ResultHandle
as an identifier for the job which can later be used to retrieve the actual results once the job has finished.
from pytket import Circuit
from pytket.extensions.qiskit import AerStateBackend
circ = Circuit(2, 2)
circ.Rx(0.3, 0).Ry(0.5, 1).CRz(-0.6, 1, 0)
backend = AerStateBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ)
The exact arguments to process_circuit()
and the means of retrieving results back are dependent on the type of data the Backend
can produce and whether it samples measurements or calculates the internal state of the quantum system.
Shots and Sampling¶
Running a Circuit
on a quantum computer invovles applying the instructions to some quantum system to modify its state. Whilst we know that this state will form a vector (or linear map) in some Hilbert space, we cannot directly inspect it and obtain a complex vector/matrix to return to the classical host process. The best we can achieve is performing measurements to collapse the state and obtain a bit of information in the process. Since the measurements are not deterministic, each run of the Circuit
samples from some distribution. By obtaining many shots (the classical readout from each full run of the Circuit
from the initial state), we can start to predict what the underlying measurement distrubution looks like.
The interaction with a QPU (or a simulator that tries to imitate a device by sampling from the underlying complex statevector) is focused around requesting shots for a given Circuit
. The number of shots required is passed to process_circuit()
. The result is retrieved using get_result()
; and the shots are then given as a table from get_shots()
: each row of the table describes a shot in the order of execution, and the columns are the classical bits from the Circuit
.
from pytket import Circuit
from pytket.extensions.qiskit import AerBackend
circ = Circuit(2, 2)
circ.H(0).X(1).measure_all()
backend = AerBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ, n_shots=20)
shots = backend.get_result(handle).get_shots()
print(shots)
[[1 1]
[0 1]
[0 1]
[0 1]
[0 1]
[0 1]
[0 1]
[0 1]
[1 1]
[1 1]
[1 1]
[1 1]
[0 1]
[0 1]
[1 1]
[1 1]
[1 1]
[0 1]
[0 1]
[1 1]]
For most applications, we are interested in the probability of each measurement outcome, so we need many shots for each experiment for high precision (it is quite typical to ask for several thousand or more). Even if we expect a single sharp peak in the distribution, as is the case from many of the popular textbook quantum algorithms (Deutsch-Jozsa, Bernstein-Vazirani, Shor, etc.), we will generally want to take many shots to help account for noise.
If we don’t care about the temporal order of the shots, we can instead retrieve a compact summary of the frequencies of observed results. The dictionary returned by get_counts()
maps tuples of bits to the number of shots that gave that result (keys only exist in the dictionary if this is non-zero). If probabilities are preferred to frequencies, we can apply the utility method probs_from_counts()
.
from pytket import Circuit
from pytket.extensions.qiskit import AerBackend
from pytket.utils import probs_from_counts
circ = Circuit(2, 2)
circ.H(0).X(1).measure_all()
backend = AerBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ, n_shots=2000)
counts = backend.get_result(handle).get_counts()
print(counts)
print(probs_from_counts(counts))
Counter({(np.uint8(1), np.uint8(1)): np.int64(1004), (np.uint8(0), np.uint8(1)): np.int64(996)})
{(np.uint8(0), np.uint8(1)): np.float64(0.498), (np.uint8(1), np.uint8(1)): np.float64(0.502)}
Note
process_circuit
returns a handle to the computation to perform the quantum computation asynchronously. Non-blocking operations are essential when running circuits on remote devices, as submission and queuing times can be long. The handle may then be used to retrieve the results with get_result
. If asynchronous computation is not needed, for example when running on a local simulator, pytket provides the shortcut pytket.backends.Backend.run_circuit()
that will immediately execute the circuit and return a BackendResult
.
Statevector and Unitary Simulation with TKET Backends¶
Any form of sampling from a distribution will introduce sampling error and (unless it is a seeded simulator) non-deterministic results, whereas we could get much better accuracy and repeatability if we have the exact state of the underlying physical quantum system. Some simulators will provide direct access to this. The get_state()
method will give the full representation of the physical state as a vector in the \(2^n\)-dimensional Hilbert space, whenever the underlying simulator provides this.
from pytket import Circuit
from pytket.extensions.qiskit import AerStateBackend
circ = Circuit(3)
circ.H(0).CX(0, 1).S(1).X(2)
backend = AerStateBackend()
compiled_circ = backend.get_compiled_circuit(circ)
state = backend.run_circuit(compiled_circ).get_state()
print(state.round(5))
[ 0. +0.j 0.70711+0.j 0. +0.j 0. +0.j
0. +0.j 0. +0.j -0. +0.j 0. +0.70711j]
Note
We have rounded the results here because simulators typically introduce a small amount of floating-point error, so killing near-zero entries gives a much more readable representation.
The majority of Backend
s will run the Circuit
on the initial state \(|0\rangle^{\otimes n}\). However, because we can form the composition of Circuit
s, we want to be able to test them with open inputs. When the Circuit
is purely quantum, we can represent its effect as an open circuit by a unitary matrix acting on the \(2^n\)-dimensional Hilbert space. The AerUnitaryBackend
from pytket-qiskit
is designed exactly for this.
from pytket import Circuit
from pytket.extensions.qiskit import AerUnitaryBackend
circ = Circuit(2)
circ.H(0).CX(0, 1)
backend = AerUnitaryBackend()
compiled_circ = backend.get_compiled_circuit(circ)
unitary = backend.run_circuit(compiled_circ).get_unitary()
print(unitary.round(5))
[[ 0.70711+0.j 0. +0.j 0.70711-0.j 0. +0.j]
[ 0. +0.j 0.70711+0.j 0. +0.j 0.70711-0.j]
[ 0. +0.j 0.70711+0.j 0. +0.j -0.70711+0.j]
[ 0.70711+0.j 0. +0.j -0.70711+0.j 0. +0.j]]
Whilst the drive for quantum hardware is driven by the limited scalability of simulators, using statevector and unitary simulators will see long-term practical use to obtain high-precision results as well as for verifying the correctness of circuit designs. For the latter, we can assert that they match some expected reference state, but simply comparing the vectors/matrices may be too strict a test given that they could differ by a global phase but still be operationally equivalent. The utility methods compare_statevectors()
and compare_unitaries()
will compare two vectors/matrices for approximate equivalence accounting for global phase.
from pytket.utils.results import compare_statevectors
import numpy as np
ref_state = np.asarray([1, 0, 1, 0]) / np.sqrt(2.) # |+0>
gph_state = np.asarray([1, 0, 1, 0]) * 1j / np.sqrt(2.) # i|+0>
prm_state = np.asarray([1, 1, 0, 0]) / np.sqrt(2.) # |0+>
print(compare_statevectors(ref_state, gph_state)) # Differ by global phase
print(compare_statevectors(ref_state, prm_state)) # Differ by qubit permutation
True
False
Be warned that simulating any Circuit
that interacts with classical data (e.g. conditional gates and measurements) or deliberately collapses the quantum state (e.g. OpType.Collapse
and OpType.Reset
) would not yield a deterministic result in the system’s Hilbert space, so these will be rejected by the Backend
.
Interpreting Results¶
Once we have obtained these results, we still have the matter of understanding what they mean. This corresponds to asking “which (qu)bit is which in this data structure?”
By default, the bits in readouts (shots and counts) are ordered in Increasing Lexicographical Order (ILO) with respect to their UnitID
s. That is, the register c
will be listed completely before any bits in register d
, and within each register the indices are given in increasing order. Many quantum software platforms including Qiskit and pyQuil will natively use the reverse order (Decreasing Lexicographical Order - DLO), so users familiar with them may wish to request that the order is changed when retrieving results.
from pytket.circuit import Circuit, BasisOrder
from pytket.extensions.qiskit import AerBackend
circ = Circuit(2, 2)
circ.X(1).measure_all() # write 0 to c[0] and 1 to c[1]
backend = AerBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ, n_shots=10)
result = backend.get_result(handle)
print(result.get_counts()) # ILO gives (c[0], c[1]) == (0, 1)
print(result.get_counts(basis=BasisOrder.dlo)) # DLO gives (c[1], c[0]) == (1, 0)
Counter({(np.uint8(0), np.uint8(1)): np.int64(10)})
Counter({(np.uint8(1), np.uint8(0)): np.int64(10)})
The choice of ILO or DLO defines the ordering of a bit sequence, but this can still be interpreted into the index of a statevector in two ways: by mapping the bits to a big-endian (BE) or little-endian (LE) integer. Every statevector and unitary in pytket
uses a BE encoding (if LE is preferred, note that the ILO-LE interpretation gives the same result as DLO-BE for statevectors and unitaries, so just change the basis
argument accordingly). The ILO-BE convention gives unitaries of individual gates as they typically appear in common textbooks [1].
from pytket.circuit import Circuit, BasisOrder
from pytket.extensions.qiskit import AerUnitaryBackend
circ = Circuit(2)
circ.CX(0, 1)
backend = AerUnitaryBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ)
result = backend.get_result(handle)
print(result.get_unitary())
print(result.get_unitary(basis=BasisOrder.dlo))
[[1.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 1.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 1.+0.j]
[0.+0.j 0.+0.j 1.+0.j 0.+0.j]]
[[1.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 1.+0.j]
[0.+0.j 0.+0.j 1.+0.j 0.+0.j]
[0.+0.j 1.+0.j 0.+0.j 0.+0.j]]
Suppose that we only care about a subset of the measurements used in a Circuit
. A shot table is a numpy.ndarray
, so it can be filtered by column selections. To identify which columns need to be retained/removed, we are able to predict their column indices from the Circuit
object. pytket.Circuit.bit_readout
maps Bit
s to their column index (assuming the ILO convention).
from pytket import Circuit, Bit
from pytket.extensions.qiskit import AerBackend
from pytket.utils import expectation_from_shots
circ = Circuit(3, 3)
circ.Rx(0.3, 0).CX(0, 1).CZ(1, 2) # Generate the state we want to consider
circ.H(1) # Measure ZXY operator qubit-wise
circ.Rx(0.5, 2)
circ.measure_all()
backend = AerBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ, 2000)
shots = backend.get_result(handle).get_shots()
# To extract the expectation value for ZIY, we only want to consider bits c[0] and c[2]
bitmap = compiled_circ.bit_readout
shots = shots[:, [bitmap[Bit(0)], bitmap[Bit(2)]]]
print(expectation_from_shots(shots))
0.006000000000000005
If measurements occur at the end of the Circuit
, then we can associate each measurement to the qubit that was measured. qubit_readout
gives the equivalent map to column indices for Qubit
s, and qubit_to_bit_map
relates each measured Qubit
to the Bit
that holds the corresponding measurement result.
from pytket import Circuit, Qubit, Bit
circ = Circuit(3, 2)
circ.Rx(0.3, 0).CX(0, 1).CZ(1, 2)
circ.Measure(0, 0)
circ.Measure(2, 1)
print(circ.bit_readout)
print(circ.qubit_readout)
print(circ.qubit_to_bit_map)
{c[0]: 0, c[1]: 1}
{q[0]: 0, q[2]: 1}
{q[0]: c[0], q[2]: c[1]}
For more control over the bits extracted from the results, we can instead call get_result()
. The BackendResult
object returned wraps up all the information returned from the experiment and allows it to be projected into any preferred way of viewing it. In particular, we can provide the list of Bit
s we want to look at in the shot table/counts dictionary, and given the exact permutation we want (and similarly for the permutation of Qubit
s for statevectors/unitaries).
from pytket import Circuit, Bit, Qubit
from pytket.extensions.qiskit import AerBackend, AerStateBackend
circ = Circuit(3)
circ.H(0).Ry(-0.3, 2)
state_b = AerStateBackend()
circ = state_b.get_compiled_circuit(circ)
handle = state_b.process_circuit(circ)
# Make q[1] the most-significant qubit, so interesting state uses consecutive coefficients
result = state_b.get_result(handle)
print(result.get_state([Qubit(1), Qubit(0), Qubit(2)]))
circ.measure_all()
shot_b = AerBackend()
circ = shot_b.get_compiled_circuit(circ)
handle = shot_b.process_circuit(circ, n_shots=2000)
result = shot_b.get_result(handle)
# Marginalise out q[0] from counts
print(result.get_counts())
print(result.get_counts([Bit(1), Bit(2)]))
[ 0.63003676+0.j -0.32101976+0.j 0.63003676+0.j -0.32101976+0.j
0. +0.j 0. +0.j 0. +0.j 0. +0.j]
Counter({(np.uint8(1), np.uint8(0), np.uint8(0)): np.int64(841), (np.uint8(0), np.uint8(0), np.uint8(0)): np.int64(794), (np.uint8(0), np.uint8(0), np.uint8(1)): np.int64(202), (np.uint8(1), np.uint8(0), np.uint8(1)): np.int64(163)})
Counter({(np.uint8(0), np.uint8(0)): np.int64(1635), (np.uint8(0), np.uint8(1)): np.int64(365)})
Expectation Value Calculations¶
One of the most common calculations performed with a quantum state \(\left| \psi \right>\) is to obtain an expectation value \(\langle \psi | H | \psi \rangle\). For many applications, the operator \(H\) can be expressed as a tensor product of Pauli matrices, or a linear combination of these. Given any (pure quantum) Circuit
and any Backend
, the utility methods get_pauli_expectation_value()
and get_operator_expectation_value()
will generate the expectation value of the state under some operator using whatever results the Backend
supports. This includes adding measurements in the appropriate basis (if needed by the Backend
), running get_compiled_circuit()
, and obtaining and interpreting the results. For operators with many terms, it can optionally perform some basic measurement reduction techniques to cut down the number of Circuit
s actually run by measuring multiple terms with simultaneous measurements in the same Circuit
.
from pytket import Circuit, Qubit
from pytket.extensions.qiskit import AerBackend
from pytket.partition import PauliPartitionStrat
from pytket.pauli import Pauli, QubitPauliString
from pytket.utils import get_pauli_expectation_value, get_operator_expectation_value
from pytket.utils.operators import QubitPauliOperator
circ = Circuit(3)
circ.Rx(0.3, 0).CX(0, 1).CZ(1, 2) # Generate the state we want to consider
backend = AerBackend()
zxy = QubitPauliString({
Qubit(0) : Pauli.Z,
Qubit(1) : Pauli.X,
Qubit(2) : Pauli.Y})
xzi = QubitPauliString({
Qubit(0) : Pauli.X,
Qubit(1) : Pauli.Z})
op = QubitPauliOperator({
QubitPauliString() : 0.3,
zxy : -1,
xzi : 1})
print(get_pauli_expectation_value(
circ,
zxy,
backend,
n_shots=2000))
print(get_operator_expectation_value(
circ,
op,
backend,
n_shots=2000,
partition_strat=PauliPartitionStrat.CommutingSets))
0.009000000000000008
(0.3030000000000001+0j)
If you want a greater level of control over the procedure, then you may wish to write your own method for calculating \(\langle \psi | H | \psi \rangle\). This is simple multiplication if we are given the statevector \(| \psi \rangle\), but is slightly more complicated for measured systems. Since each measurement projects into either the subspace of +1 or -1 eigenvectors, we can assign +1 to each 0
readout and -1 to each 1
readout and take the average across all shots. When the desired operator is given by the product of multiple measurements, the contribution of +1 or -1 is dependent on the parity (XOR) of each measurement result in that shot. pytket
provides some utility functions to wrap up this calculation and apply it to either a shot table (expectation_from_shots()
) or a counts dictionary (expectation_from_counts()
).
from pytket import Circuit
from pytket.extensions.qiskit import AerBackend
from pytket.utils import expectation_from_counts
circ = Circuit(3, 3)
circ.Rx(0.3, 0).CX(0, 1).CZ(1, 2) # Generate the state we want to consider
circ.H(1) # Want to measure expectation for Pauli ZXY
circ.Rx(0.5, 2) # Measure ZII, IXI, IIY separately
circ.measure_all()
backend = AerBackend()
compiled_circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(compiled_circ, 2000)
counts = backend.get_result(handle).get_counts()
print(counts)
print(expectation_from_counts(counts))
Counter({(np.uint8(0), np.uint8(0), np.uint8(1)): np.int64(431), (np.uint8(0), np.uint8(0), np.uint8(0)): np.int64(402), (np.uint8(0), np.uint8(1), np.uint8(1)): np.int64(375), (np.uint8(0), np.uint8(1), np.uint8(0)): np.int64(366), (np.uint8(1), np.uint8(0), np.uint8(1)): np.int64(115), (np.uint8(1), np.uint8(0), np.uint8(0)): np.int64(112), (np.uint8(1), np.uint8(1), np.uint8(0)): np.int64(110), (np.uint8(1), np.uint8(1), np.uint8(1)): np.int64(89)})
0.0020000000000000018
Note
expectation_from_shots()
and expectation_from_counts()
take into account every classical bit in the results object. If the expectation value of interest is a product of only a subset of the measurements in the Circuit
(as is the case when simultaneously measuring several commuting operators), then you will want to filter/marginalise out the ignored bits when performing this calculation.
Guidance for Writing Hardware-Agnostic Code¶
Writing code for experiments that can be retargeted to different Backend
s can be a challenge, but has many great payoffs for long-term developments. Being able to experiment with new devices and simulators helps to identify which is best for the needs of the experiment and how this changes with the experiment parameters (such as size of chemical molecule being simulated, or choice of model to train for a neural network). Being able to react to changes in device availability helps get your results faster when contesting against queues for device access or downtime for maintenance, in addition to moving on if a device is retired from live service and taking advantage of the newest devices as soon as they come online. This is especially important in the near future as there is no clear frontrunner in terms of device, manufacturer, or even fundamental quantum technology, and the rate at which they are improving performance and scale is so high that it is essential to not get left behind with old systems.
One of the major counter-arguments against developing hardware-agnostic experiments is that the manual incorporation of the target architecture’s connectivity and noise characteristics into the circuit design and choice of error mitigation/detection/correction strategies obtains the optimal performance from the device. The truth is that hardware characteristics are highly variable over time, invalidating noise models after only a few hours [2] and requiring regular recalibration. Over the lifetime of the device, this could lead to some qubits or couplers becoming so ineffective that they are removed from the system by the providers, giving drastic changes to the connectivity and admissible circuit designs. The instability of the experiment designs is difficult to argue when the optimal performance on one of today’s devices is likely to be surpassed by an average performance on another device a short time after.
We have already seen that devices and simulators will have different sets of requirements on the Circuit
s they can accept and different types of results they can return, so hardware-agnosticism will not always come for free. The trick is to spot these differences and handle them on-the-fly at runtime. The design of the Backend
class in pytket
aims to expose the fundamental requirements that require consideration for circuit design, compilation, and result interpretation in such a way that they can easily be queried at runtime to dynamically adapt the experiment procedure. All other aspects of backend interaction that are shared between different Backend
s are then unified for ease of integration. In practice, the constraints of the algorithm might limit the choice of Backend
, or we might choose to forego the ability to run on statevector simulators so that we only have to define the algorithm to calculate using counts, but we can still be agnostic within these categories.
The first point in an experiment where you might have to act differently between Backend
s is during Circuit
construction. A Backend
may support a non-universal fragment of the Circuit
language, typically relating to their interaction with classical data – either full interaction, no mid-circuit measurement and conditional operations, or no measurement at all for statevector and unitary simulators. If the algorithm chosen requires mid-circuit measurement, then we must sacrifice some freedom of choice of Backend
to accommodate this. For safety, it could be beneficial to include assertions that the Backend
provided meets the expectations of the algorithm.
from pytket import Circuit
from pytket.extensions.qiskit import AerBackend #, AerStateBackend
from pytket.predicates import NoMidMeasurePredicate
backend = AerBackend() # Choose backend in one place
# backend = AerStateBackend() # A backend that is incompatible with the experiment
# For algorithms using mid-circuit measurement, we can assert this is valid
qkd = Circuit(1, 3)
qkd.H(0).Measure(0, 0) # Prepare a random bit in the Z basis
qkd.H(0).Measure(0, 1).H(0) # Eavesdropper measures in the X basis
qkd.Measure(0, 2) # Recipient measures in the Z basis
assert backend.supports_counts # Using AerStateBackend would fail at this check
assert NoMidMeasurePredicate() not in backend.required_predicates
compiled_qkd = backend.get_compiled_circuit(qkd)
handle = backend.process_circuit(compiled_qkd, n_shots=1000)
print(backend.get_result(handle).get_counts())
Counter({(np.uint8(0), np.uint8(0), np.uint8(1)): np.int64(134), (np.uint8(1), np.uint8(1), np.uint8(0)): np.int64(133), (np.uint8(1), np.uint8(0), np.uint8(1)): np.int64(129), (np.uint8(0), np.uint8(1), np.uint8(1)): np.int64(128), (np.uint8(1), np.uint8(1), np.uint8(1)): np.int64(126), (np.uint8(0), np.uint8(1), np.uint8(0)): np.int64(121), (np.uint8(1), np.uint8(0), np.uint8(0)): np.int64(116), (np.uint8(0), np.uint8(0), np.uint8(0)): np.int64(113)})
Note
The same effect can be achieved by assert backend.valid_circuit(qkd)
after compilation. However, when designing the compilation procedure manually, it is unclear whether a failure for this assertion would come from the incompatibility of the Backend
for the experiment or from the compilation failing.
Otherwise, a practical solution around different measurement requirements is to separate the design into “state circuits” and “measurement circuits”. At the point of running on the Backend
, we can then choose to either just send the state circuit for statevector calculations or compose it with the measurement circuits to run on sampling Backend
s.
At runtime, we can check whether a particular result type is supported using the Backend.supports_X
properties (such as supports_counts
), whereas restrictions on the Circuit
s supported can be inspected with required_predicates
.
Whilst the demands of each Backend
on the properties of the Circuit
necessitate different compilation procedures, using the default compilation sequences provided with get_compiled_circuit
handles compiling generically.
Similarly, every Backend
can use process_circuit()
identically. Additional Backend
-specific arguments (such as the number of shots required or the seed for a simulator) will just be ignored if passed to a Backend
that does not use them.
For the final steps of retrieving and interpreting the results, it suffices to just case-split on the form of data we can retrieve again with Backend.supports_X
.
from pytket import Circuit
from pytket.extensions.qiskit import AerBackend #, AerStateBackend
from pytket.utils import expectation_from_counts
import numpy as np
backend = AerBackend() # Choose backend in one place
# backend = AerStateBackend() # Alternative backend with different requirements and result type
# For many algorithms, we can separate the state preparation from measurements
circ = Circuit(2) # Apply e^{0.135 i pi XY} to the initial state
circ.H(0).V(1).CX(0, 1).Rz(-0.27, 1).CX(0, 1).H(0).Vdg(1)
measure = Circuit(2, 2) # Measure the YZ operator via YI and IZ
measure.V(0).measure_all()
if backend.supports_counts:
circ.append(measure)
circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(circ, n_shots=2000)
expectation = 0
if backend.supports_state:
yz = np.asarray([
[0, 0, -1j, 0],
[0, 0, 0, 1j],
[1j, 0, 0, 0],
[0, -1j, 0, 0]])
svec = backend.get_result(handle).get_state()
expectation = np.vdot(svec, yz.dot(svec))
else:
counts = backend.get_result(handle).get_counts()
expectation = expectation_from_counts(counts)
print(expectation)
-0.016000000000000014
Batch Submission¶
Current public-access quantum computers tend to implement either a queueing or a reservation system for mediating access. Whilst the queue-based access model gives relative fairness, guarantees availability, and maximises throughput and utilisation of the hardware, it also presents a big problem to the user with regards to latency. Whenever a circuit is submitted, not only must the user wait for it to be run on the hardware, but they must wait longer for their turn before it can even start running. This can end up dominating the time taken for the overall experiment, especially when demand is high for a particular device.
We can mitigate the problem of high queue latency by batching many Circuit
s together. This means that we only have to wait the queue time once, since after the first Circuit
is run the next one is run immediately rather than joining the end of the queue.
To maximise the benefits of batch submission, it is advisable to generate as many of your Circuit
s as possible at the same time to send them all off together. This is possible when, for example, generating every measurement circuit for an expectation value calculation, or sampling several parameter values from a local neighbourhood in a variational procedure. The method process_circuits()
(plural) will then submit all the provided Circuit
s simultaneously and return a ResultHandle
for each Circuit
to allow each result to be extracted individually for interpretation. Since there is no longer a single Circuit
being handled from start to finish, it may be necessary to store additional data to record how to interpret them, like the set of Bit
s to extract for each Circuit
or the coefficient to multiply the expectation value by.
from pytket import Circuit
from pytket.extensions.qiskit import IBMQBackend
from pytket.utils import expectation_from_counts
backend = IBMQBackend("ibm_brisbane")
state = Circuit(3)
state.H(0).CX(0, 1).CX(1, 2).X(0)
# Compute expectation value for -0.3i ZZZ + 0.8 XZZ + 1.2 XXX
zzz = Circuit(3, 3)
zzz.measure_all()
xzz = Circuit(3, 3)
xzz.H(0).measure_all()
xxx = Circuit(3, 3)
xxx.H(0).H(1).H(2).measure_all()
circ_list = []
for m in [zzz, xzz, xxx]:
c = state.copy()
c.append(m)
circ_list.append(c)
coeff_list = [
-0.3j, # ZZZ
0.8, # XZZ
1.2 # XXX
]
circ_list = backend.get_compiled_circuits(circ_list)
handle_list = backend.process_circuits(circ_list, n_shots=2000)
result_list = backend.get_results(handle_list)
expectation = 0
for coeff, result in zip(coeff_list, result_list):
counts = result.get_counts()
expectation += coeff * expectation_from_counts(counts)
print(expectation)
(1.2047999999999999-0.0015000000000000013j)
Note
Currently, only some devices (e.g. those from IBMQ, Quantinuum and Amazon Braket) support a queue model and benefit from this methodology, though more may adopt this in future. The AerBackend
simulator and the QuantinuumBackend
can take advantage of batch submission for parallelisation. In other cases, process_circuits
will just loop through each Circuit
in turn.
Embedding into Qiskit¶
Not only is the goal of tket to be a device-agnostic platform, but also interface-agnostic, so users are not obliged to have to work entirely in tket to benefit from the wide range of devices supported. For example, Qiskit is currently the most widely adopted quantum software development platform, providing its own modules for building and compiling circuits, submitting to backends, applying error mitigation techniques and combining these into higher-level algorithms. Each Backend
in pytket
can be wrapped up to imitate a Qiskit backend, allowing the benefits of tket to be felt in existing Qiskit projects with minimal work.
Below we show how the CirqStateSampleBackend
from the pytket-cirq
extension can be used with its default_compilation_pass()
directly in qiskit.
from qiskit.primitives import BackendSampler
from qiskit_algorithms import Grover, AmplificationProblem
from qiskit.circuit import QuantumCircuit
from pytket.extensions.cirq import CirqStateSampleBackend
from pytket.extensions.qiskit.tket_backend import TketBackend
cirq_simulator = CirqStateSampleBackend()
backend = TketBackend(cirq_simulator, cirq_simulator.default_compilation_pass())
sampler_options = {"shots":1024}
qsampler = BackendSampler(backend, options=sampler_options)
oracle = QuantumCircuit(2)
oracle.cz(0, 1)
def is_good_state(bitstr):
return sum(map(int, bitstr)) == 2
problem = AmplificationProblem(oracle=oracle, is_good_state=is_good_state)
grover = Grover(sampler=qsampler)
result = grover.amplify(problem)
print("Top measurement:", result.top_measurement)
Note
Since Qiskit may not be able to solve all of the constraints of the chosen device/simulator, some compilation may be required after a circuit is passed to the TketBackend
, or it may just be preferable to do so to take advantage of the sophisticated compilation solutions provided in pytket
. Upon constructing the TketBackend
, you can provide a pytket
compilation pass to apply to each circuit, e.g. TketBackend(backend, backend.default_compilation_pass())
. Some experimentation may be required to find a combination of qiskit.transpiler.PassManager
and pytket
compilation passes that executes successfully.
Advanced Backend Topics¶
Simulator Support for Expectation Values¶
Some simulators will have dedicated support for fast expectation value calculations. In this special case, they will provide extra methods get_pauli_expectation_value()
and get_operator_expectation_value()
, which take a Circuit
and some operator and directly return the expectation value. Again, we can check whether a Backend
has this feature with supports_expectation
.
from pytket import Circuit, Qubit
from pytket.extensions.qiskit import AerStateBackend
from pytket.pauli import Pauli, QubitPauliString
from pytket.utils.operators import QubitPauliOperator
backend = AerStateBackend()
state = Circuit(3)
state.H(0).CX(0, 1).V(2)
xxy = QubitPauliString({
Qubit(0) : Pauli.X,
Qubit(1) : Pauli.X,
Qubit(2) : Pauli.Y})
zzi = QubitPauliString({
Qubit(0) : Pauli.Z,
Qubit(1) : Pauli.Z})
iiz = QubitPauliString({
Qubit(2) : Pauli.Z})
op = QubitPauliOperator({
QubitPauliString() : -0.5,
xxy : 0.7,
zzi : 1.4,
iiz : 3.2})
assert backend.supports_expectation
state = backend.get_compiled_circuit(state)
print(backend.get_pauli_expectation_value(state, xxy))
print(backend.get_operator_expectation_value(state, op))
-1.0
0.20000000000000068
Asynchronous Job Submission¶
In the near future, as we look to more sophisticated algorithms and larger problem instances, the quantity and size of Circuit
s to be run per experiment and the number of shots required to obtain satisfactory precision will mean the time taken for the quantum computation could exceed that of the classical computation. At this point, the overall algorithm can be sped up by maintaining maximum throughput on the quantum device and minimising how often the quantum device is left idle whilst the classical system is determining the next Circuit
s to send. This can be achieved by writing your algorithm to operate asynchronously.
The intended semantics of the Backend()
methods are designed to enable asynchronous execution of quantum programs whenever admissible from the underlying API provided by the device/simulator. process_circuit()
and process_circuits()
will submit the Circuit
(s) and immediately return.
The progress can be checked by querying circuit_status()
. If this returns a CircuitStatus
matching StatusEnum.COMPLETED
, then get_X()
will obtain the results and return immediately, otherwise it will block the thread and wait until the results are available.
import asyncio
from pytket import Circuit
from pytket.backends import StatusEnum
from pytket.extensions.qiskit import IBMQBackend
from pytket.utils import expectation_from_counts
backend = IBMQBackend("ibm_brisbane")
state = Circuit(3)
state.H(0).CX(0, 1).CX(1, 2).X(0)
# Compute expectation value for -0.3i ZZZ + 0.8 XZZ + 1.2 XXX
zzz = Circuit(3, 3)
zzz.measure_all()
xzz = Circuit(3, 3)
xzz.H(0).measure_all()
xxx = Circuit(3, 3)
xxx.H(0).H(1).H(2).measure_all()
circ_list = []
for m in [zzz, xzz, xxx]:
c = state.copy()
c.append(m)
circ_list.append(backend.get_compiled_circuit(c))
coeff_list = [
-0.3j, # ZZZ
0.8, # XZZ
1.2 # XXX
]
handle_list = backend.process_circuits(circ_list, n_shots=2000)
async def check_at_intervals(backend, handle, interval):
while True:
await asyncio.sleep(interval)
status = backend.circuit_status(handle)
if status.status in (StatusEnum.COMPLETED, StatusEnum.ERROR):
return status
async def expectation(backend, handle, coeff):
await check_at_intervals(backend, handle, 5)
counts = backend.get_result(handle).get_counts()
return coeff * expectation_from_counts(counts)
async def main():
task_set = set([asyncio.create_task(expectation(backend, h, c)) for h, c in zip(handle_list, coeff_list)])
done, pending = await asyncio.wait(task_set, return_when=asyncio.ALL_COMPLETED)
sum = 0
for t in done:
sum += await t
print(sum)
asyncio.run(main())
(1.2087999999999999-0.002400000000000002j)
In some cases you may want to end execution early, perhaps because it is taking too long or you already have all the data you need. You can use the cancel()
method to cancel the job for a given ResultHandle
. This is recommended to help reduce load on the devices if you no longer need to run the submitted jobs.
Note
Asynchronous submission is currently available with the IBMQBackend
, QuantinuumBackend
, BraketBackend
and AerBackend
. It will be extended to others in future updates.
Persistent Handles¶
Being able to split your processing into distinct procedures for Circuit
generation and result interpretation can help improve throughput on the quantum device, but it can also provide a way to split the processing between different Python sessions. This may be desirable when the classical computation to interpret the results and determine the next experiment parameters is sufficiently intensive that we would prefer to perform it offline and only reserve a quantum device once we are ready to run more. Furthermore, resuming with previously-generated results could benefit repeatability of experiments and better error-safety since the logged results can be saved and reused.
Some Backend
s support persistent handles, in that the ResultHandle
object can be stored and the associated results obtained from another instance of the same Backend
in a different session. This is indicated by the boolean persistent_handles
property of the Backend
. Use of persistent handles can greatly reduce the amount of logging you would need to do to take advantage of this workflow.
from pytket import Circuit
from pytket.extensions.qiskit import IBMQBackend
backend = IBMQBackend("ibm_brisbane")
circ = Circuit(3, 3)
circ.X(1).CZ(0, 1).CX(1, 2).measure_all()
circ = backend.get_compiled_circuit(circ)
handle = backend.process_circuit(circ, n_shots=1000)
# assert backend.persistent_handles
print(str(handle))
counts = backend.get_result(handle).get_counts()
print(counts)
('5e8f3dcbbb7d8500119cfbf6', 0)
{(0, 1, 1): 1000}
from pytket.backends import ResultHandle
from pytket.extensions.qiskit import IBMQBackend
backend = IBMQBackend("ibm_brisbane")
handle = ResultHandle.from_str("('5e8f3dcbbb7d8500119cfbf6', 0)")
counts = backend.get_result(handle).get_counts()
print(counts)
{(0, 1, 1): 1000}
Result Serialization¶
When performing experiments using Backend
s, it is often useful to be able to easily store and retrieve the results for later analysis or backup.
This can be achieved using native serialiaztion and deserialization of BackendResult
objects from JSON compatible dictionaries, using the to_dict()
and from_dict()
methods.
import tempfile
import json
from pytket import Circuit
from pytket.backends.backendresult import BackendResult
from pytket.extensions.qiskit import AerBackend
circ = Circuit(2, 2)
circ.H(0).CX(0, 1).measure_all()
backend = AerBackend()
handle = backend.process_circuit(circ, 10)
res = backend.get_result(handle)
with tempfile.TemporaryFile('w+') as fp:
json.dump(res.to_dict(), fp)
fp.seek(0)
new_res = BackendResult.from_dict(json.load(fp))
print(new_res.get_counts())
Counter({(np.uint8(0), np.uint8(0)): np.int64(6), (np.uint8(1), np.uint8(1)): np.int64(4)})