Education / Quantum Programming

Quantum Programming · Chapter 11

Quantum Hardware Execution

How do we run a tested program on hardware and interpret the result?

In this chapter · 3 sections
  1. 11.1 Execution Setup
  2. 11.2 Runtime Workloads
  3. 11.3 Result Analysis
Reading guide · preparation and approach

Starting point: Earlier programming chapters; relevant constructions from Quantum Computing and Quantum Algorithms.

Anchor example: Compile a validated Bell circuit for a backend, guard its hardware submission behind explicit options, and plan how hardware counts would be compared with local predictions.

This lesson includes explanations, worked examples, and problems with solutions. Advanced results whose proofs are outside the chapter are identified explicitly.

Our compiled circuit was written for a device we described ourselves, and checked by simulation. Real hardware brings things a simulation never had. It needs an account. It costs part of a limited execution budget. And its results describe one physical device at one moment, with that device’s errors.

How do we run a tested program on hardware and interpret the result? First we set up the run: choose a backend, keep our credentials out of the code, and make an accidental submission impossible. The circuit must also be compiled into the exact form the backend accepts. Then we submit, which raises questions of job records, execution modes and budgets. Finally, a result from hardware is worth only as much as the record that comes with it, so we decide what to save and how to compare it with our predictions.

The program in this chapter was checked only in its local mode. Its hardware branch has not been run for this course, and no hardware results are reported here.

11.1 Execution Setup

We take the Bell circuit, which we have tested locally. A local simulation and a hardware run of it make different claims: the first predicts what an ideal model does, while the second reports what a particular device did at a particular time. Before any submission we must decide which device to use, how to prove we may use it, and what form of circuit it accepts. We take the device and the account first, then the circuit.

11.1.1 Backend selection and credentials

Which backend can run our circuit? It needs at least as many qubits as the circuit, and it must support the circuit’s instructions and any classical control the circuit uses. Using it also requires an account, identified by a secret token. Where should that token live? Never in a source file, since source files are shared and stored in version histories. Qiskit Runtime saves the account separately on our computer, and QiskitRuntimeService() reads it from there.

A harder question is how to make spending execution time impossible by accident. A script that submitted a job whenever it ran would do so every time someone ran the course checks. So the companion compiles locally by default, and it submits only when the user types two options on the command line: python chapter_11.py --submit --backend NAME.

Python’s standard module argparse reads such options. Each add_argument line declares one. action="store_true" makes --submit a switch, true when present and false otherwise. parse_args() reads what the user typed, and parser.error stops the program with a message if --submit comes without a backend name.

One more guard matters. Another program may import this file to reuse its build function. Python sets the variable __name__ to "__main__" only when a file is run directly, so the block under if __name__ == "__main__": never runs on import. Importing the file therefore never submits, and neither does importing the Runtime package.

Problem 11.1. Does importing the Runtime package submit a circuit?

Worked solution

No. A run call on a remote execution object is the submission action. Keep that action separated from module import and local testing.

11.1.2 Executable circuits and mapped observables

What form must the circuit take before the backend accepts it? IBM’s devices accept only circuits already written in the backend’s native instructions, on its physical qubits, respecting its connections. Such a circuit is called an ISA circuit, after the backend’s instruction set architecture, its list of native instructions. Producing one is what our pass manager did for the toy line. Here generate_preset_pass_manager(backend=backend, …) reads the native gates and connections from the backend’s own target.

For local runs, the companion uses GenericBackendV2(2, seed=3), an invented two-qubit backend with realistic native gates, rz, sx, x and cx. Compiling for it turns H into Z rotations and a \(\sqrt X\), as before, and the measured Bell circuit needs one CNOT.

Compilation also moves qubits, and that affects observables. Suppose we use an Estimator to ask for \(\langle ZZ\rangle\) on logical qubits 0 and 1, and the layout places them on physical qubits 5 and 6 of a large device. The observable must then act on 5 and 6. observable.apply_layout(compiled.layout) rewrites it accordingly. A Sampler needs no such step, because the measurements are part of the compiled circuit and still write to the bits we chose.

Problem 11.2. Why map an Estimator observable after layout?

Worked solution

The logical qubits may have moved. Measuring Z on a different physical qubit can answer a different question even when the compiled state is correct.

11.2 Runtime Workloads

With an ISA circuit and a backend, the next step uses real execution time. What happens when we submit, how do we find our job again, and how do we keep the cost under control? We follow a single submission first, then consider how groups of jobs are organized and paid for.

11.2.1 Submitting and retrieving jobs

What does a submission look like in code? SamplerV2 from Runtime is the remote counterpart of the local sampler we already know. Its input mode=backend says where its jobs run. Calling run sends the PUBs and returns a job at once, while the work may still wait in a queue. job.job_id() is a string that identifies the job. We print or save it immediately, so that a later program can retrieve the result even if this one stops. job.result() waits for the result, and we read the counts exactly as before.

This branch uses a separately configured account. It was checked against the Runtime documentation but not run on hardware for this course.

11.2.2 Execution modes and budgets

Suppose a program sends many related jobs, as an optimization loop does. Must each one wait in the queue separately? The mode decides. In job mode, which our companion uses, each job is queued on its own. A session reserves a window on one device, so the jobs of an iterative program run one after another without queuing again. A batch submits a group of independent jobs together, so the service can schedule them efficiently. Which modes are available, and how they are charged, depends on the service and the account.

What will a submission cost? We estimate it before submitting, from the number of circuits, the shots, and the expected time on the device. No fixed free allowance should be assumed. A one-line statement can launch a large amount of work, so the explicit submission boundary stays visible in any code we share.

11.3 Result Analysis

When counts come back from hardware, what can we claim from them? Two things limit the claim. We can compare a run only with what we recorded about it, and we can compare it with our prediction only after separating sampling fluctuation from device error. We take the record first, then the comparison.

11.3.1 Metadata and provenance

Suppose we rerun the job a month later and the counts differ. Is that a bug, or the device? Without a record we cannot tell. Devices are recalibrated often, and their errors drift, so the same source circuit can give different results at different times.

So we save the history of the result, its provenance: the backend name, the time of execution, the package versions, the compiled circuit and its layout, the shots, the options, and the metadata the service returns. The compiled circuit matters most, because it is what actually ran. A table of counts without these facts cannot be compared reliably with another run.

11.3.2 Simulation, hardware, and uncertainty

How should hardware counts be compared with our prediction? The ideal Bell state gives 00 and 11 with probability one half each, and never 01 or 10. Sampling alone moves the frequency of 00 by a standard error of \(\sqrt{0.25/1024}=1/64\approx0.016\) at 1024 shots. But sampling can never produce 01 or 10 from the ideal state. So if those strings appear, the cause is the device, not the finite number of shots. Telling apart the kinds of device error is a question we take up with noise.

The companion’s local path compiles for the invented backend and prints the compiled gate counts: two Z rotations, one \(\sqrt X\), one CNOT, the two measurements and a barrier. The remote path compiles for the chosen backend, submits 1024 shots, prints the job identifier and then the counts. Only the local path runs in the automated checks.

"""Runnable companion for Quantum Programming, Chapter 11."""
import argparse
from qiskit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2

def build(backend):
    circuit = QuantumCircuit(2)
    circuit.h(0); circuit.cx(0, 1); circuit.measure_all()
    return generate_preset_pass_manager(backend=backend, optimization_level=1,
                                        seed_transpiler=17).run(circuit)

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--submit", action="store_true", help="Explicitly submit a paid/allocated hardware job")
    parser.add_argument("--backend", help="Exact accessible hardware backend name")
    args = parser.parse_args()
    if not args.submit:
        compiled = build(GenericBackendV2(2, seed=3))
        print("Local compilation only; no account accessed and no job submitted.")
        print(compiled.count_ops())
    else:
        if not args.backend:
            parser.error("--backend is required with --submit")
        service = QiskitRuntimeService()  # Uses a separately configured account.
        backend = service.backend(args.backend)
        compiled = build(backend)
        job = SamplerV2(mode=backend).run([compiled], shots=1024)
        print("Submitted job:", job.job_id())
        print(job.result()[0].data.meas.get_counts())

Companion script: chapter_11.py.

Problem 11.3. What is missing from a claim that hardware “gave 95% success”?

Worked solution

The task, success definition, shots and uncertainty, device and time, circuit and layout, execution options, and comparison baseline are all needed.

All our circuits so far measured only at the end. Some hardware can measure a qubit partway through a circuit and act on the result before the circuit finishes. How can a measurement change the remaining computation?

Sources and further study: IBM, Runtime SamplerV2.

Summary

Questions and worked problems

These exercises draw on the whole chapter. Attempt each question before opening a hint, explain your reasoning, then compare it with the worked solution.

1. Quick check §

A three-qubit GHZ circuit is compiled for a two-qubit synthetic backend, GenericBackendV2(2). What happens, and what should the program check before compiling for a real backend?

from qiskit import QuantumCircuit
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit.transpiler import generate_preset_pass_manager

ghz = QuantumCircuit(3)
ghz.h(0)
ghz.cx(0, 1)
ghz.cx(1, 2)
ghz.measure_all()
generate_preset_pass_manager(backend=GenericBackendV2(2, seed=3), optimization_level=1).run(ghz)
Hint 1

The backend has fewer qubits than the circuit.

Hint 2

Compare circuit.num_qubits with the backend’s size and supported instructions.

Worked solution

The pass manager raises a TranspilerError (“Number of qubits greater than device”). Before compiling, a program should check width, supported instructions (including any dynamic-circuit features), and connectivity, and fail with a clear message rather than attempt a submission that cannot run.

Revisit the relevant explanation

2. Quick check §

List the information that should be saved with every hardware job so that its results can be interpreted and compared later.

Hint 1

Think about what changes between runs: device, calibration, software, compilation.

Hint 2

Section 11.3.1 lists the essentials.

Worked solution

Save the job identifier immediately; the backend name; submission and execution times; package versions; the compiled (ISA) circuit and its layout; shots and primitive options; any mitigation settings; the returned metadata; and the raw results. Device calibrations drift, so without this record a later run, or the same circuit on another device, cannot be compared meaningfully.

Revisit the relevant explanation

3. Quick check §

A study plans \(50\) circuits with \(4096\) shots each, and each shot takes about \(200\) μs on the device, including reset. Estimate the device time. Why is this only a lower bound on the cost?

Hint 1

\(50\times4096\times200\ \mu\mathrm s\).

Hint 2

Compilation, queueing, and per-job overheads add time.

Worked solution

About \(50\times4096\times2\times10^{-4}\ \mathrm s\approx41\) s of shot time. Actual usage also includes per-job and per-circuit overhead, classical processing, possible repetitions, and calibration circuits for mitigation; queue time adds wall-clock delay. Budgets should be estimated before submission rather than discovered afterwards.

Revisit the relevant explanation

4. Worked problem §

A circuit flips logical qubit 0 and is compiled with initial_layout [2, 0, 1], so logical qubit 0 sits on physical qubit 2. (a) Evaluate the logical observable IIZ (Z on logical qubit 0) on the compiled circuit without remapping. (b) Remap it with apply_layout and evaluate again. (c) Which value is correct?

Hint 1

After compilation, the circuit acts on physical qubit indices.

Hint 2

apply_layout moves each Pauli to the physical qubit of its logical qubit.

Worked solution

(a) Unmapped, IIZ measures physical qubit 0, which holds logical qubit 1 in \(\ket0\): the result is \(+1\). (b) apply_layout turns it into ZII, Z on physical qubit 2, and the result is \(-1\). (c) \(-1\) is correct: logical qubit 0 was flipped. An Estimator workload on a compiled circuit must use observables mapped through the layout, or it silently measures the wrong qubits.

from qiskit import QuantumCircuit, transpile
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import CouplingMap

circuit = QuantumCircuit(3)
circuit.x(0)
compiled = transpile(circuit, coupling_map=CouplingMap.from_line(3), initial_layout=[2, 0, 1],
                     basis_gates=["x", "cx", "rz", "sx"])
logical = SparsePauliOp("IIZ")
mapped = logical.apply_layout(compiled.layout)
estimator = StatevectorEstimator()
assert estimator.run([(compiled, logical)]).result()[0].data.evs == 1
assert estimator.run([(compiled, mapped)]).result()[0].data.evs == -1

Revisit the relevant explanation

5. Worked problem §

Save a compiled circuit for provenance using Qiskit’s QPY format, reload it, and check that the reloaded circuit equals the original. Why store the compiled circuit rather than only the source code that produced it?

Hint 1

qpy.dump and qpy.load read and write binary files.

Hint 2

Recompiling later may give a different circuit.

Worked solution

QPY serialization round-trips the compiled circuit exactly, including parameters and layout information, and the reloaded circuit compares equal. Storing the compiled circuit records what actually ran: recompiling the same source later can give a different layout or gate sequence, because of different software versions, different backend calibrations, or seeds. Results are about executed circuits, not intended ones.

import io
from qiskit import QuantumCircuit, qpy
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit.transpiler import generate_preset_pass_manager

bell = QuantumCircuit(2)
bell.h(0)
bell.cx(0, 1)
bell.measure_all()
compiled = generate_preset_pass_manager(backend=GenericBackendV2(2, seed=3), optimization_level=1,
                                        seed_transpiler=17).run(bell)
buffer = io.BytesIO()
qpy.dump(compiled, buffer)
buffer.seek(0)
assert qpy.load(buffer)[0] == compiled

Revisit the relevant explanation

6. Worked problem §

Compile the Bell circuit for GenericBackendV2(2, seed=3) and inspect the operation counts. Which native gates appear, and how many two-qubit gates does the ISA circuit contain?

Hint 1

Check backend.operation_names.

Hint 2

The Hadamard becomes RZ and SX gates.

Worked solution

The backend’s operations include cx, rz, sx, x, reset, delay, and measure. The compiled Bell circuit contains one CNOT, two RZ, one SX, a barrier, and two measurements: the Hadamard was rewritten in RZ and SX. Inspecting the ISA circuit before submission confirms that only supported instructions remain and gives the counts that a resource estimate needs.

from qiskit import QuantumCircuit
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit.transpiler import generate_preset_pass_manager

backend = GenericBackendV2(2, seed=3)
bell = QuantumCircuit(2)
bell.h(0)
bell.cx(0, 1)
bell.measure_all()
compiled = generate_preset_pass_manager(backend=backend, optimization_level=1, seed_transpiler=17).run(bell)
counts = dict(compiled.count_ops())
assert counts["cx"] == 1 and set(counts) <= set(backend.operation_names) | {"barrier"}

Revisit the relevant explanation

7. Worked problem §

Simulate the Bell circuit on a noisy model of the synthetic backend (AerSimulator.from_backend). Estimate the fraction of shots giving 00 or 11 with its standard error. What does this number describe, and what does it not?

Hint 1

Standard error \(\sqrt{\hat p(1-\hat p)/N}\).

Hint 2

GenericBackendV2 has synthetic error rates.

Worked solution

With \(4000\) shots the success fraction is about \(0.99\) with a standard error near \(0.0015\). It describes this synthetic noise model under these settings: the error rates of GenericBackendV2 are generated, not measured on any device. It does not predict a real backend’s performance, and population success alone would not distinguish a Bell state from a classical mixture; that needs a second measurement basis.

from qiskit import QuantumCircuit, transpile
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit_aer import AerSimulator

simulator = AerSimulator.from_backend(GenericBackendV2(2, seed=3))
bell = QuantumCircuit(2)
bell.h(0)
bell.cx(0, 1)
bell.measure_all()
shots = 4000
counts = simulator.run(transpile(bell, simulator, seed_transpiler=1), shots=shots, seed_simulator=5).result().get_counts()
success = (counts.get("00", 0) + counts.get("11", 0)) / shots
error = (success * (1 - success) / shots) ** 0.5
assert 0.95 < success < 1 and error < 0.005

Revisit the relevant explanation

8. Misconception §

A student reports that “the device gave \(99\%\) Bell fidelity,” based on a run with GenericBackendV2. What is wrong with the claim, and what would a hardware claim need?

Hint 1

GenericBackendV2 is synthetic.

Hint 2

Fidelity is not the same as the fraction of 00 and 11 outcomes.

Worked solution

GenericBackendV2 is a synthetic backend with generated properties; no device was involved, so nothing was measured about real hardware. The quantity measured, the fraction of 00 and 11, is a population success rate, not a fidelity. A hardware claim needs the device name and date, the compiled circuit and layout, shots and uncertainty, a fidelity estimate that includes coherence (for example from \(XX\) and \(YY\) measurements as well as \(ZZ\)), and a statement of any mitigation applied.

Revisit the relevant explanation

9. Extension §

Teaching code must never launch hardware work by accident. Write a small guard that raises an error unless submission is explicitly enabled and an exact backend name is given, and test both branches without any account access.

Hint 1

Separate the decision from the submission call.

Hint 2

Raise ValueError for an incomplete request.

Worked solution

The guard returns “local” when submission is not requested, raises a ValueError when submission is requested without a backend name, and returns the backend name otherwise; only then would the caller construct a service and submit. Testing the guard needs no credentials. Keeping the decision in one tested function makes the submission boundary explicit, as the chapter’s companion does with --submit and --backend.

def submission_target(submit, backend=None):
    if not submit:
        return "local"
    if not backend:
        raise ValueError("an exact backend name is required to submit")
    return backend

assert submission_target(False) == "local"
assert submission_target(True, "chosen_backend") == "chosen_backend"
try:
    submission_target(True)
    raise AssertionError("expected an error")
except ValueError:
    pass

Revisit the relevant explanation

10. Extension §

A parameter sweep of \(100\) values is to be run on hardware. Compare (a) \(100\) separate jobs, each with one bound circuit; (b) one job with a single PUB containing the parameterized circuit and a \(100\)-row value array. Which costs are reduced by (b), and which are not? What must still be recorded per parameter value?

Hint 1

Per-job overheads include queueing and setup.

Hint 2

Shots per parameter value are unchanged.

Worked solution

Option (b) compiles once, submits once, and incurs per-job overhead and queue time once, and its results arrive as one array in parameter order. It does not reduce the shots per value or the device time spent on them. Each value’s result must still be linked to its parameter and to the job’s metadata, and if calibration drifts during a long job, the timing within the job matters too.

Revisit the relevant explanation

Search this book

Search the course.