QPU and Cloud Workflows
Three backends support real hardware: Braket (IonQ, Rigetti, IQM, AQT and others), Qiskit Runtime (IBM Eagle and Heron), and IQM through the software of IQM itself. All three support calls that wait for the result and calls that do not, inspection before a run, and a warning before anything runs on paid hardware. The hardware devices are called quantum processing units (QPUs).
| Braket | Qiskit Runtime | IQM | |
|---|---|---|---|
| Registration | Lazy, provider="braket". |
Pass a QiskitRuntimeConfig. register("qiskit-runtime", ...) is needed only for the string name. |
Lazy, provider="iqm". |
| Hardware reached | IonQ, Rigetti, IQM and AQT, through device_arn. |
IBM Eagle and Heron, through backend_name. |
IQM Crystal and Star devices, through url and quantum_computer. |
| Simulator without an account | Yes: device_arn=None runs LocalSimulator. |
No. Use a FakeBackendV2 through backend_instance. |
No. Use an IQMFake* backend through backend_instance. |
| Selects the device by | device_arn. |
backend_name (or "least_busy"), or a backend object. |
url and quantum_computer, or a backend object. |
| Analytic evaluation | With shots=0, on simulators only. |
Never: shots defaults to 1024. |
Never: the hardware only samples. |
| Measurement | Expectation result types, and counts and probability result types for sample() and probabilities(). |
EstimatorV2 or SamplerV2, chosen with primitive=. |
Counts only. The expectation values are computed from them. |
| The job handle has | result(), status(), cancel() and task_id(). |
result(), status(), cancel() and job_id(). |
result(), status(), cancel(), job_id() and counts(). |
| Extra | braket |
qiskit |
iqm |
There is a second way to reach IQM hardware: a Braket device_arn that points at an
IQM QPU, which is billed through Amazon Web Services (AWS). The iqm provider talks
to IQM Resonance directly instead. It is also the only way to use the fake backends
of IQM and its Star architectures, which use MOVE gates.
The examples below use the PyTorch interface (interface="torch"). The supported
pattern is to train a differentiable nn.Module circuit on a simulator, and then to
use its weights on the QPU, because QPU backends refuse gradients unless you
turn them on. The NumPy interface also works on QPU
backends: pass the weights explicitly, with circuit(weights, x) or
circuit.submit(weights, x).
Cost warnings
The first compilation for a paid backend (a cloud simulator or a QPU) issues a single
CostWarning. You can catch it or filter it with the standard warnings module:
import warnings
from eigenkit import CostWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore", CostWarning)
circuit = Circuit(blocks=..., provider=qpu_config, interface="torch")
QPU configurations also do the following:
- They require
shots > 0, because analytic evaluation is not possible on hardware. - They set
gradient_method="none". Calling.backward()on a QPU result,circuit.jacobian()or a gradient-based optimiser raises a clear error, which points to the workflow of training on a simulator and then running on the device. This does not happen when the configuration setsenable_gradients=True(see Gradients on hardware).
Calls that wait for the result
Circuit(...)(x) works on QPU backends in the same way as on simulators, but it
waits until the job is finished:
from eigenkit import Circuit, BraketConfig
config = BraketConfig(
device_arn="arn:aws:braket:us-east-1::device/qpu/ionq/Aria-1",
shots=1000,
)
circuit = Circuit(blocks=..., provider=config, interface="torch")
y = circuit(x) # blocks until the QPU returns
Submitting without waiting
For long jobs, use circuit.submit(x), which returns a JobHandle immediately:
handle = circuit.submit(x)
print(handle.status()) # "QUEUED", "RUNNING", "COMPLETED", ...
# Do other work...
result = handle.result() # blocks until the job is done
JobHandle.result() returns the expectation value of each observable, as a NumPy
array. The protocol itself declares result() and status(). The three handles that
exist also implement cancel(). The Braket handle has task_id(), while the Qiskit
Runtime and IQM handles have job_id(). The IQM handle also has counts(), which
returns the raw histograms behind the expectation values, one for each measurement
group. A custom handle can implement only the two methods that the protocol names.
submit() accepts only a single 1-D input, and no batches. For a batch, loop in
Python.
Dry runs
The backends that can run in the cloud have Circuit.dry_run_info(). It returns a
dictionary that describes the prepared circuit, without submitting anything. Use it to
check the circuit before you submit it:
The backends return different keys. They do not share a common set with additions, and only three keys appear in all of them.
| Key | Braket | Qiskit Runtime | IQM | Meaning |
|---|---|---|---|---|
shots |
✓ | ✓ | ✓ | The number of shots per circuit run. |
n_observables |
✓ | ✓ | ✓ | The number of observables measured. |
n_circuit_runs_per_call |
✓ | ✓ | ✓ | The key number: how many circuits the device runs for one forward call. |
device_arn |
✓ | The chosen Braket device, or None for the local simulator. |
||
local_backend |
✓ | The LocalSimulator that runs when device_arn is None. |
||
n_qubits |
✓ | The number of qubits of the circuit. | ||
n_gates |
✓ | The number of gates, as built. | ||
n_unique_pauli_terms |
✓ | The number of distinct Pauli terms. With shots, they are measured in one task for each group of terms that commute qubit-wise. Exact runs measure them all in one task. | ||
is_cloud, is_qpu |
✓ | Whether the target is billed, and whether it is hardware. | ||
backend_name |
✓ | ✓ | The device that the job would go to. | |
is_fake |
✓ | True for a FakeBackendV2, so nothing is billed. |
||
is_local |
✓ | True for an IQMFake* backend, so nothing is charged. |
||
primitive |
✓ | "estimator" or "sampler". |
||
transpile_mode |
✓ | "parametric" or "per_call". |
||
n_measurement_groups |
✓ | ✓ | The number of groups of observables that commute qubit-wise. This sets the number of circuit runs per call when sampling. | |
optimization_level |
✓ | ✓ | The effort of the transpiler that produced the numbers below. | |
n_logical_qubits |
✓ | ✓ | The number of qubits before transpilation. | |
n_physical_qubits |
✓ | ✓ | The number of qubits of the target device. | |
transpiled_gate_counts |
✓ | ✓ | The gates after transpilation, counted by gate name. IQM reports one entry for each measurement basis. | |
transpiled_depth |
✓ | ✓ | The depth of the circuit after transpilation. IQM reports one entry for each measurement basis, and leaves out both keys in "per_call" mode, which transpiles when the circuit is called. |
n_circuit_runs_per_call is the key to read before you submit. It is the number of
circuits that the device runs for a single forward call.
Resources after transpilation
On Qiskit Runtime and IQM, Circuit.resources() reports the resources that the circuit
uses on its target once it is transpiled. These are the numbers that decide whether the circuit
survives the noise of the device. Nothing is submitted, and a fake backend_instance
gives the same report locally:
from iqm.qiskit_iqm.fake_backends.fake_garnet import IQMFakeGarnet
from qiskit_ibm_runtime.fake_provider import FakeFez
from eigenkit import Circuit, IQMConfig, QiskitRuntimeConfig
for config in (
QiskitRuntimeConfig(
backend_instance=FakeFez(),
primitive="sampler",
optimization_level=3,
seed_transpiler=0,
),
IQMConfig(backend_instance=IQMFakeGarnet(), optimization_level=3, seed_transpiler=0),
):
report = Circuit(blocks=..., provider=config).resources()
print(
report["n_2q_gates"],
report["depth_2q"],
report["routing_overhead_2q"],
report["estimated_success_probability"],
)
| Key | Meaning |
|---|---|
gate_counts |
The native gates after transpilation, including the measurements. |
n_2q_gates, two_qubit_gate_counts |
The two-qubit operations, in total and by name (cz, ecr, or move on IQM). |
depth, depth_2q |
The depth of the circuit, and the depth that counts only the layers of two-qubit gates. |
n_2q_gates_unrouted |
The same count for the circuit on the native gates of the device, with every qubit connected to every other. |
routing_overhead_2q |
n_2q_gates - n_2q_gates_unrouted, which is the number of two-qubit gates that the coupling map adds. |
physical_qubits |
Where the layout placed each logical qubit. |
estimated_success_probability |
The product of 1 - error over every operation and readout, from the error rates of the target (for the IQM fake backends, from their error profile). Idle time between operations is not counted. On the fake IBM devices, it matched the measured loss of contrast of a kernel within 0.02. On the IQM fake backends, it overestimates deep circuits: 0.85 against a measured 0.42 for a 4-qubit ZZ kernel with 64 two-qubit gates on Garnet. |
Circuit.transpiled_circuit() returns the Qiskit circuit itself, for
.draw("mpl"). Set seed_transpiler to compare two configurations fairly, because
routing without a seed varies from run to run.
Quantum kernels use the same machinery. See
Quantum Kernels, On hardware. A kernel matrix is one job,
and QuantumKernel.submit_kernel_matrix() and retrieve_kernel_matrix() let a long
queue finish in another session.
Sampling: counts, probabilities, and QAOA on hardware
Hardware does not return expectation values. It returns a histogram of measured bitstrings. Braket, Qiskit Runtime and IQM give access to this histogram directly:
In the keys, qubit 0 is the most significant bit, so "100" means that qubit 0 is
excited and the others are in the ground state. This is the same convention as
everywhere else in the library. The counts add up to the shots configured for the
backend.
circuit.probabilities(weights, x) turns the same histogram into the full
distribution over 2**n_qubits states. On hardware, it is an estimate from the
frequencies of the shots, not the exact distribution: basis states that were not
sampled come back as exact zeros, without smoothing. It is limited to 24 qubits.
Above that, a budget of shots cannot fill a dense distribution anyway, so use
sample() instead.
This is what lets the Quantum Approximate Optimization Algorithm (QAOA) read a
solution from real hardware, because it reads its answer from probabilities(). The
optimisation loop still needs a method without gradients, because hardware backends
declare gradient_method="none" unless the configuration sets
enable_gradients=True:
from eigenkit.optimization import QAOA
from eigenkit import IQMConfig
solver = QAOA(
layers=1,
provider=IQMConfig(url="https://resonance.iqm.tech/", shots=4000),
optimizer="COBYLA",
)
optimizer="spsa" is also accepted. Simultaneous perturbation stochastic
approximation (SPSA) estimates the gradient from two evaluations per iteration,
whatever the number of parameters, and makes a third evaluation to record the loss.
This keeps the number of jobs low on hardware.
How an expectation value is computed from a histogram
When a backend samples, the Pauli terms of the observables are sorted into
measurement groups. Two terms share a group when no qubit needs two different
bases for them, which means that they commute qubit-wise. Each group is one circuit:
it rotates the qubits (H for X, and Sdg then H for Y), measures them, and
then computes the value from the parity of the counts. all_z(4) is one group, not
four circuits, and the Z and ZZ terms of a QAOA cost Hamiltonian also make one
group.
n_measurement_groups in dry_run_info() is this number, and on a sampling backend,
n_circuit_runs_per_call is equal to it. Set group_commuting=False to run one
circuit per term instead. This runs more circuits, but each term gets the full budget
of shots for itself, which is the point of comparison when you want to attribute the
shot noise to each term.
Recommended workflow: train on a simulator, run on the QPU
QPU calls are too expensive for parameter-shift gradients, because one backward pass
costs at least 2 * n_weights jobs. The supported workflow is:
- Train on a simulator (
provider="numpy"orprovider="pennylane"). - Save the weights with
torch.save(circuit.state_dict(), ...). - Build the same circuit with a QPU provider, and load the weights.
- Run the inference with
circuit(x)orcircuit.submit(x).
# Train.
sim_circuit = Circuit(blocks=blocks, interface="torch")
# ... training loop ...
torch.save(sim_circuit.state_dict(), "weights.pt")
# Deploy.
qpu_circuit = Circuit(
blocks=blocks,
provider=BraketConfig(device_arn=..., shots=1000),
interface="torch",
)
qpu_circuit.load_state_dict(torch.load("weights.pt"))
y = qpu_circuit(x_test)
Gradients on hardware
To train or fine-tune on the device itself, set enable_gradients=True in the device
configuration (BraketConfig, QiskitRuntimeConfig or IQMConfig). The backend then
reports gradient_method="parameter_shift", and .backward(), circuit.jacobian()
and gradient-based optimisers run the parameter-shift rule on the hardware:
qpu_circuit = Circuit(
blocks=blocks,
provider=IQMConfig(
url="https://resonance.iqm.tech/",
shots=2000,
enable_gradients=True,
),
interface="torch",
)
loss = qpu_circuit(x).sum()
loss.backward() # 2 * n_weights forward calls on the device
Compiling for a paid device with this flag set issues a second CostWarning, which
states how many evaluations one backward pass needs there, for example "one backward
pass costs at least 24 circuits per sample (12 parameter-shift evaluations x 2 circuits
each) at shots=2000". The unit is the one that the cost warning of the device counts: circuits
on IQM, Primitive Unified Blocs (PUBs) on Qiskit Runtime, and tasks on Braket. This figure is a minimum. A batch
multiplies it by the batch size, and input gradients, or weights wrapped in Scale,
Multiply or Sum angles, add more. Local fake backends issue neither warning. On
Braket, the flag matters only for QPU ARNs, because the local and cloud simulators
compute gradients in either case.
Gradients estimated from shots are noisy: the standard deviation of each entry scales
as 1/sqrt(shots). If the cost is the concern, SPSA (optimizer="spsa") needs two
evaluations per step to estimate the gradient, whatever the number of weights, and it
needs no flag.
Notes for each backend
Braket
device_arn=Noneruns on theLocalSimulator, in the same process.- An Amazon Resource Name (ARN) that contains
/qpu/(Rigetti, IonQ, IQM, AQT and others) turns on the QPU mode automatically. - Amazon Web Services bills the tasks on its cloud simulators and QPUs. eigenkit does not estimate the price.
- The AWS credentials are read from the environment, through the standard boto3 chain.
- The
submit()of the compiled circuit takes two keyword arguments.method=chooses what the task computes:"run"(the default, which measures the observables),"sample","probabilities"or"zero_state_probability"(BraketCompiled.submit_methodslists them). Each returns what the waiting method of the same name returns.tags={...}puts AWS tags on a cloud task. The tags follow the AWS rules (no key that starts withaws:, and only letters, digits, spaces and_ . : / = + - @), and they are checked before anything is submitted. The local simulator ignores tags. circuit.compiled.attach(task_arn)builds aBraketJobHandleagain for a cloud task submitted earlier, for example after the process that submitted it has restarted. Pass the samemethodas the one the task was submitted with. Values with the shape of another method or circuit raise aValueError.handle.result()raises aRuntimeError, which names the task and its state, when the task ended asFAILEDorCANCELLED, or when the polling timed out.
Braket shot rules and result types
sample() reads the measurement counts of the task, so it needs shots > 0
everywhere, and a QPU needs a number of shots within the range of the device. In the
keys, qubit 0 is the most significant bit, over the whole circuit, and a qubit that
no gate acts on reads 0.
probabilities() and zero_state_probability() read the first result type that the
device publishes for the configured shots, in the order Probability,
StateVector, Amplitude, DensityMatrix. The capabilities of the device
(GetDevice) decide, so the choice is fixed for each device and number of shots:
| Device | sample |
probabilities, shots=0 |
probabilities, shots > 0 |
zero_state_probability, shots=0 |
zero_state_probability, shots > 0 |
|---|---|---|---|---|---|
| Local simulator | Counts. | Probability. |
Probability. |
Probability. |
Probability. |
| SV1 | Counts. | The Amplitude of every basis state, squared, up to 17 qubits. |
Probability, up to 15 qubits. |
The Amplitude of the all-zeros state, squared, for any number of qubits. |
Probability, up to 15 qubits. |
| DM1 | Counts. | The diagonal of the DensityMatrix, up to 8 qubits. |
Probability, up to 15 qubits. |
The [0, 0] entry of the DensityMatrix, up to 8 qubits. |
Probability, up to 15 qubits. |
| QPU | Counts. | Refused, because QPUs need shots > 0. |
Probability, up to 15 qubits. |
Refused, because QPUs need shots > 0. |
Probability, up to 15 qubits. |
"Counts" means no result type: the task returns its measurement counts, which needs
shots > 0 on every device. SV1 and DM1 publish Probability only for 1 to
100,000 shots, so their answers with shots=0 come from the exact result types
instead, and they match the local simulator. With shots > 0, probabilities() is
the estimate from the frequencies of the shots. A device that publishes none of the
four result types for the requested shots raises a ValueError before anything is
submitted.
The limits on the number of qubits in the table are the limits of Braket itself. They
are checked before anything is submitted, so a wider circuit fails with a
ValueError and not at AWS:
- The managed simulators and QPUs of Braket return
Probabilityfor at most15qubits (List of result types), and both methods read it over every qubit. So every cloud device stops at15qubits withshots > 0, which is below the limit of24qubits of the denseprobabilities(). On the local simulator,Probabilityis limited only by memory, andsample()has no limit anywhere. - The exact
probabilities()of SV1 names all the2**n_qubitsbasis states inside the program, and Braket limits the program of a quantum task to 5 megabytes (Amazon Braket quotas). The list alone takes 3.0 megabytes at17qubits, and 6.3 megabytes at18.zero_state_probability()names one state, so it has no limit. - DM1 returns a density matrix of at most
8qubits (DM1 results), and both exact methods need the density matrix over every qubit, which is4**8, or65,536, entries at the limit.
Above the limit of Probability, use sample() for the histogram, which has no
limit on the number of qubits. zero_state_probability() is then the count of the
all-zeros state divided by the number of shots. Above an exact limit, use
shots > 0 for the estimate from the frequencies of the shots (up to 15 qubits), or
sample().
Qiskit Runtime
- The token is looked up in this order: an explicit
token, then theQISKIT_IBM_TOKENenvironment variable, then the saved account file. channeldefaults to"ibm_quantum_platform". The older"ibm_quantum"channel was retired with the old IBM Quantum Platform, andQiskitRuntimeServiceno longer accepts that string.- Pass a
FakeBackendV2instance throughbackend_instancefor tests, in continuous integration or locally. No authentication is needed. -
There are two primitives, chosen with
QiskitRuntimeConfig(primitive=...). Each call submits one job, whichever primitive is used."estimator"(the default) usesEstimatorV2. Each observable becomes one Primitive Unified Bloc (PUB) in the job, son_circuit_runs_per_callequalsn_observables."sampler"usesSamplerV2, and computes the expectation values from the raw counts locally. The job holds one PUB for each measurement group, in place of one for each observable, and the budget of shots is explicit. This is what you want when the shot cost of a result is itself what you are measuring.
-
sample()andprobabilities()go throughSamplerV2, whichever primitive is configured, becauseEstimatorV2has no bitstrings to return.primitiveonly chooses how the expectation values are obtained. - Both primitives process a batch at once. The PUBs of
EstimatorV2andSamplerV2both accept a(B, n_params)array, so a batch ofBinputs stays one job, with one PUB for each observable (or for each group), in place of one job for each sample. - The
submit()of the compiled circuit takes two keyword arguments.method=chooses what the job computes:"run"(the default, which measures the observables),"sample"or"probabilities"(QiskitRuntimeCompiled.submit_methodslists them). Each returns what the waiting method of the same name returns."sample"and"probabilities"are oneSamplerV2job in theZbasis, whichever primitive is used.zero_state_probabilityis not a method ofsubmit(). Quantum kernels callsubmit_zero_state_probabilities(), which sends a whole batch as one job, andretrieve_zero_state_probabilities(job_id)attaches to it again (see Resources after transpilation and Quantum Kernels, On hardware).tags={...}puts IBM job tags on the job, each as"key=value", so that you can find it again withQiskitRuntimeService.jobs(job_tags=[...]). IBM allows at most8tags of at most86characters. Keys and values must not be empty, and a key must not contain=. The tags are checked before anything is submitted.submit()takes one sample: 2-Dweightsorinputsraise aValueError. circuit.compiled.attach(job_id)builds aQiskitRuntimeJobHandleagain for a job submitted earlier, looked up through theQiskitRuntimeServiceof the backend, for example after the process that submitted it has restarted. Pass the samemethodas the one the job was submitted with. Results with the shape of another method or circuit raise aValueError. A fake backend has no service, soattach()needs a real IBM backend.
IQM
from eigenkit import IQMConfig
config = IQMConfig(
url="https://resonance.iqm.tech/",
quantum_computer="garnet", # omit to take the server's default
shots=2048,
)
- The token is looked up in this order: an explicit
token, then theIQM_TOKENenvironment variable, then whatever iqm-client finds by itself. The address of the server can come fromIQM_SERVER_URLin place ofurl. - Pass an
IQMFakeAdonis(),IQMFakeApollo()orIQMFakeDeneb()instance throughbackend_instancefor tests, in continuous integration or locally. They run on Aer with a model of the noise of IQM, with no token and no network, and the tests of this backend use them. - The service of IQM returns only counts. There is no estimator primitive, so
every expectation value is computed from a histogram of bitstrings.
shots > 0is required, andsample()andprobabilities()are fully supported, not added as an afterthought. - The native gates are
r(anRXrotation with a phase) andcz. Devices with the Star topology route two-qubit gates through a resonator withmovegates, which the transpiler adds automatically. -
transpile_modebalances the cost of compilation against the depth of the circuit:"parametric"(the default) transpiles once for each measurement group, and binds the values at each call. The transpiler cannot merge rotations whose angles it cannot see, so the circuit is longer than it needs to be. A 2-qubit block of an encoding and a variational layer gave6rgates in parametric mode, against3when the values were bound first."per_call"binds the values first and transpiles at every call. This gives shallower circuits and a better fidelity, at the cost of one transpilation per evaluation. This cost is small compared with the time in the queue, but not compared with a local simulator.
-
A batch is one job per sample. The measurement groups of a single sample do share a job. Quantum kernels are the exception:
zero_state_probabilities()binds every row into the one transpiled circuit, and submits them as a single job.submit_zero_state_probabilities()returns without waiting for it, andretrieve_zero_state_probabilities(job_id)attaches to it again.
API reference
eigenkit.core.provider.JobHandle
Bases: Protocol
A handle to a job running on a quantum processing unit (QPU) or in the cloud.
compiled.submit(...) returns it without waiting for the job. Call .result()
to wait for the job and combine its results.
result()
Wait until the job finishes, then return its expectation values.
The shape follows the same rule as
CompiledCircuit.run():
(n_obs,) for a single sample, and (B, n_obs) for a batch.
status()
Return the state of the job as the provider reports it, without waiting.
The strings are the provider's own. For example, Braket returns
"CREATED", "QUEUED", "RUNNING", "COMPLETED", "FAILED" or
"CANCELLED". Compare them within one provider, not across providers.
eigenkit.core.provider.CostWarning
Bases: UserWarning
Warning issued before a submission to a paid quantum backend.
A paid backend is a quantum processing unit (QPU) or a cloud simulator that charges for use. The first compilation for such a device issues this warning, so that costs cannot build up without the user noticing.