Case Study: Taking a Circuit from Simulator to Hardware

Executive Summary

The circuit works. It has worked for weeks on AerSimulator. You submit it to a real backend and get an error about ISA circuits, then a distribution that looks nothing like the simulation, then a queue time longer than your afternoon.

This case study is the checklist nobody writes down: every difference between a simulated run and a hardware run, in the order you will encounter them. Working through it once converts hardware from mysterious to merely noisy.

Skills applied

  • Transpiling to a backend's ISA and understanding why it is mandatory (§8.9).
  • Choosing between Sampler and Estimator primitives (§8.11).
  • Reading backend properties to select qubits (§8.13).
  • Interpreting hardware results against a noisy simulation rather than an ideal one.

Phase 1: The circuit that works locally

from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator

qc = QuantumCircuit(3, 3)
qc.h(0)
qc.cx(0, 1)
qc.cx(1, 2)
qc.measure([0, 1, 2], [0, 1, 2])

sim = AerSimulator()
result = sim.run(qc, shots=4096).result()
print(result.get_counts())     # {'000': ~2048, '111': ~2048}

Clean GHZ state, two outcomes, nothing else. This is the baseline against which everything below is a degradation.

Phase 2: Difference one — the circuit must be rewritten

Submit this circuit to hardware and it is rejected. Hardware runs only its native instruction set on its actual coupling map; h and cx between arbitrary qubits are abstractions.

from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit import transpile

service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)

isa_circuit = transpile(qc, backend, optimization_level=3, seed_transpiler=42)
print(f"depth {qc.depth()} -> {isa_circuit.depth()}")
print(f"gates {len(qc.data)} -> {len(isa_circuit.data)}")

Typical result: depth 4 → 11, gates 6 → 18. The circuit tripled in size before executing a single shot. On a device where qubits 0, 1, 2 are not mutually adjacent, SWAPs appear here.

Rule. Always inspect the transpiled circuit, not the one you wrote. The transpiled version is what runs and what determines fidelity.

Phase 3: Difference two — primitives, not run

Modern hardware access goes through primitives, which handle batching, error mitigation options, and session management:

from qiskit_ibm_runtime import SamplerV2 as Sampler

sampler = Sampler(mode=backend)
job = sampler.run([isa_circuit], shots=4096)
result = job.result()
counts = result[0].data.c.get_counts()

Note result[0].data.c — the classical register's name. If you created the circuit with QuantumCircuit(3, 3) the register is named c by default; a custom ClassicalRegister name changes this path. This trips people up constantly.

Use Sampler when you want a distribution over bitstrings. Use Estimator when you want $\langle O \rangle$ for an observable — it handles the basis rotations for you and returns a number with an error estimate, which is almost always what a variational algorithm wants.

Phase 4: Difference three — the results

Hardware, 4,096 shots:

Outcome Simulator Hardware
000 2,043 1,721
111 2,053 1,606
001 0 178
010 0 149
100 0 166
011 0 92
101 0 84
110 0 100

81% in the two correct outcomes, 19% scattered. Is the device broken?

Compare against the right baseline — a noisy simulation using the backend's own properties:

from qiskit_aer import AerSimulator
noisy = AerSimulator.from_backend(backend)
noisy_counts = noisy.run(isa_circuit, shots=4096).result().get_counts()

The noisy simulation gives roughly 78–83% in the correct outcomes. The hardware is behaving exactly as its published error rates predict. Nothing is broken; the expectation was wrong.

Rule. The ideal simulator is not the right comparison for a hardware run. Compare against a noise model built from the backend's current properties, and treat a match as success.

Phase 5: Difference four — qubit selection matters

The transpiler chose a qubit triple. You can do better by reading the calibration data:

props = backend.properties()
for edge in backend.coupling_map:
    err = props.gate_error('cx', edge)
    print(edge, f"{err:.4f}")

Error rates across pairs on the same device commonly vary by 3–5×, and one or two qubits are often markedly worse than the rest. Constraining the layout to a good triple:

isa_circuit = transpile(qc, backend, initial_layout=[12, 13, 14],
                        optimization_level=3, seed_transpiler=42)

On a typical device this moves the correct-outcome fraction from ~81% to ~88% with no change to the algorithm whatsoever.

Phase 6: Difference five — everything else

Four practical realities with no simulator analogue:

  • Queue time. Jobs wait. Batch related circuits into one job, or use a Session to hold an allocation across a variational loop, or your optimizer will spend most of its wall-clock time in a queue.
  • Calibration drift. Results from Tuesday do not necessarily reproduce on Wednesday. Record backend.properties().last_update_date with every result.
  • Shot cost. Shots are metered. Estimate the precision you need first: $1/\sqrt N$ means 10,000 shots buys 1% and 1,000,000 buys 0.1%. Deciding you need 0.1% is a 100× budget decision.
  • Non-determinism. Even at a fixed seed, the device is stochastic. Report error bars.

Discussion Questions

  1. Why is the noisy simulator the correct baseline, and what does it mean when hardware underperforms it?
  2. Qubit selection bought 7 percentage points with no algorithmic change. What does that imply for comparing results between research groups?
  3. Sampler and Estimator both exist; the Estimator could be built from the Sampler. Why is it provided separately?
  4. Shot noise scales as $1/\sqrt N$. For a variational algorithm evaluating an energy 500 times per optimization run, work out the shot budget for 1% precision.

Your Turn: Extensions

  • Run the GHZ circuit on a fake backend, then on real hardware, and tabulate all three distributions.
  • Write a helper that ranks all connected qubit triples by summed CNOT error and picks the best.
  • Repeat the same job on three different days and quantify the drift.
  • Redo the experiment with Estimator and $\langle ZZZ \rangle$; compare its error bar against your own from the Sampler counts.

Key Takeaways

  • Hardware executes ISA circuits only: transpile, then inspect what transpilation produced.
  • Primitives (Sampler for distributions, Estimator for expectation values) are the modern access path; execute() is gone.
  • Compare hardware to a noise-model simulation, never to the ideal one — a match means success.
  • Qubit selection is a free and substantial fidelity lever; error rates vary several-fold across one chip.
  • Queue time, calibration drift, and metered shots are real constraints with no simulator counterpart. Plan for them.