Day 4 — Running Circuits
Lesson
C1000-179 sprint. Environment: qiskit 2.5.1, qiskit-ibm-runtime 0.48.0, qiskit-aer.
Snippets marked # not run - requires account are hardware-only flows verified
against the IBM documentation clone; everything else was executed locally.
1. QiskitRuntimeService — account setup
1.1 Channels
The channel argument is a literal with exactly three valid values (runtime 0.48):
| Channel | Meaning |
|---|---|
"ibm_quantum_platform" |
The current IBM Quantum Platform (new platform, default) |
"ibm_cloud" |
IBM Cloud |
"local" |
Local testing mode — no account, primitives run on local simulators |
The legacy "ibm_quantum" channel is retired. If the exam offers
"ibm_q", "ibm_quantum_experience", or "aer" as channels, they are wrong.
1.2 Saving credentials
# not run - requires account
from qiskit_ibm_runtime import QiskitRuntimeService
QiskitRuntimeService.save_account(
token="<44-char IBM Cloud API key>",
channel="ibm_quantum_platform",
instance="<IBM Cloud CRN or instance name>", # optional
name="personal", # optional named account
set_as_default=True, # optional
overwrite=True, # required to replace an existing saved account
)
# later, in any script:
service = QiskitRuntimeService() # loads default saved account
service = QiskitRuntimeService(name="personal") # loads a named accountKey save_account facts:
- Credentials are written to
~/.qiskit/qiskit-ibm.json. overwrite=Falseis the default — re-saving the same account name withoutoverwrite=Trueraises an error.- If no
instanceis given, an appropriate instance is auto-selected; you can steer the selection withregion("us-east"/"eu-de"),plans_preference(e.g.["open"]), andtags— all three are ignored ifinstanceis specified. - You can also pass
token=directly toQiskitRuntimeService(...)for a one-off, unsaved connection.
2. Getting a backend
# not run - requires account
backend = service.backend("ibm_brisbane") # by name
backend = service.least_busy(operational=True, simulator=False)
backend = service.least_busy(min_num_qubits=127, operational=True, simulator=False)
backends = service.backends() # list all visible backendsleast_busy(operational=True, simulator=False) is the canonical exam call:
fewest pending jobs among real, operational QPUs.
2.1 Backend properties (verified on FakeManilaV2)
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
backend = FakeManilaV2()
backend.num_qubits # 5
backend.basis_gates # ['cx', 'id', 'rz', 'sx', 'x']
backend.coupling_map # CouplingMap: [(0,1),(1,0),(1,2),(2,1),(2,3),(3,2),(3,4),(4,3)]
backend.target # Target: full ISA description (gates + qubits + errors)
backend.target.num_qubits # 5
backend.target.operation_names # basis gates + measure/delay/reset/control flowThe Target is the single source of truth for what the device supports:
instructions, which qubits/pairs they apply to, durations, and error rates.
The transpiler consumes the target; basis_gates and coupling_map are
convenience views of it.
3. ISA circuits — the hard requirement
An ISA (Instruction Set Architecture) circuit uses only the target's basis gates and respects its connectivity (qubit layout/routing).
V2 primitives on IBM hardware REJECT non-ISA circuits. They never transpile for you. You must run a preset pass manager (or
transpile) against the backend first.
from qiskit import QuantumCircuit
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_qc = pm.run(qc) # now an ISA circuit for this backendVerified locally: submitting the raw (h-containing) circuit to
SamplerV2(mode=FakeManilaV2()) raises
IBMInputValueError: 'The instruction h on qubits (0,) is not supported by the
target system. Circuits that do not match the target hardware definition are
no longer supported...'So fake backends enforce the ISA rule too — good for exam-realistic practice.
For Estimator, observables must be remapped after transpilation:
isa_obs = observable.apply_layout(isa_circuit.layout)4. Execution modes: job vs batch vs session
4.1 Definitions
- Job mode — a single primitive request submitted standalone (no context
manager).
mode=backend. Queues like any normal job. - Batch mode — a multi-job manager for independent jobs submitted all at once. Classical preprocessing of the jobs is parallelized/threaded and the quantum execution runs in quick succession. Jobs are not guaranteed to run in submission order, there is no exclusive access (other users' jobs and calibration jobs may interleave), and jobs share no state.
- Session mode — a dedicated window with exclusive access to the QPU; no other jobs (not even calibration jobs) run during your active window. Built for iterative workloads where the next job depends on the previous result (variational algorithms, VQE/QAOA loops, noise-model reuse for PEA/PEC).
4.2 Memorize this decision table
| You have... | Mode | Why |
|---|---|---|
| One primitive request | Job | Simplest; sessions/batches give zero benefit for a single job |
| Many independent jobs, all inputs ready up front | Batch | Scheduled together, parallel classical processing, cheaper than session |
| Iterative workload — next input depends on last result (VQE, QAOA, calibration loops) | Session | Exclusive access; no re-queuing between iterations |
| Need dedicated QPU access / stable noise (PEA/PEC noise learning) | Session | Nothing else runs in your window |
| Cost-sensitive multi-job workload that isn't iterative | Batch | Sessions are generally more expensive |
Docs rule of thumb: "Generally, use batch mode unless you have workloads that don't have all inputs ready at the outset." And: "Always use job mode to submit a single primitive request."
4.3 Syntax — context managers and mode=
# not run - requires account
from qiskit_ibm_runtime import QiskitRuntimeService, Session, Batch
from qiskit_ibm_runtime import SamplerV2 as Sampler, EstimatorV2 as Estimator
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
# --- Job mode: mode = a backend object ---
sampler = Sampler(mode=backend)
job = sampler.run([isa_qc])
# --- Session mode: context manager (auto-closes on exit) ---
with Session(backend=backend, max_time="25m") as session:
estimator = Estimator(mode=session)
for step in range(n_iterations):
job = estimator.run([(isa_circuit, isa_obs, params)])
params = update(job.result()) # iterative: result feeds next job
# --- Batch mode: context manager ---
with Batch(backend=backend) as batch:
sampler = Sampler(mode=batch)
jobs = [sampler.run([pub]) for pub in independent_pubs] # submit all at once
results = [j.result() for j in jobs]
# --- Non-context-manager form (must close manually) ---
session = Session(backend=backend)
estimator = Estimator(mode=session)
...
session.close()Signatures (runtime 0.48, introspected):
Session(backend: BackendV2, max_time: int | str | None = None)
Batch(backend: BackendV2, max_time: int | str | None = None)
SamplerV2(mode: BackendV2 | Session | Batch | None = None, options=None)
EstimatorV2(mode: BackendV2 | Session | Batch | str | None = None, options=None)So mode= accepts a backend object (job mode), a Session, or a Batch.
Passing a fake backend or AerSimulator as mode= gives local testing mode.
4.4 Session/batch lifecycle and timeouts
- The first job in a session/batch waits in the normal queue — no queue advantage for job #1.
- When it starts, the maximum TTL timer starts (
max_timeparameter; default 8 hours = 28800 s) and never pauses. - After each job completes, the interactive TTL timer starts. If no new job arrives within it, the workload is temporarily deactivated (not closed) and normal scheduling resumes; a new job re-activates it via the normal queue.
- When max TTL is hit, the workload ends and remaining queued jobs FAIL.
| Knob | Session | Batch |
|---|---|---|
max_time (max TTL) |
configurable, default 8 h | configurable, default 8 h |
| Interactive TTL | 60 s default, not configurable | 1 min, not configurable |
| Exclusive QPU access | Yes (calibration blocked too) | No |
| Inspect | session.details() → interactive_timeout, max_time, state, ... |
batch.details() |
Closing:
with Session(...)auto-closes on context exit — the session goes to "In progress, not accepting new jobs": already-submitted jobs finish, new submissions are rejected.session.close()— same "no new jobs, finish existing" semantics.session.cancel()— cancels all queued jobs too.- Open Plan users cannot submit session jobs (batch and job are fine).
Common exam traps
Sessionas a context manager auto-closes; you do NOT callclose()inside awithblock.- Batch jobs share no state and may execute out of order.
- Neither sessions nor batches reduce queue time for the first job.
mode=accepts backend / Session / Batch — not a string like"batch".- Interactive TTL is not configurable;
max_timeis.- Session = exclusive access; Batch = no exclusive access.
5. Running primitives: run(pubs) is asynchronous
job = sampler.run([pub1, pub2]) # returns IMMEDIATELY with a job object
job.job_id()
job.status()
result = job.result() # BLOCKS until the job completesOne run() call = one job, containing one or more PUBs. result() is the
only blocking call; everything before it is fire-and-forget.
5.1 PUBs (Primitive Unified Blocs)
A PUB is a tuple bundling one circuit with its inputs:
| Primitive | PUB shape |
|---|---|
| Sampler | (circuit,) or (circuit, parameter_values) or (circuit, parameter_values, shots) |
| Estimator | (circuit, observables) or (circuit, observables, parameter_values) or (..., precision) |
A bare circuit passed in the list is coerced to (circuit,). Per-PUB shots
override the run(..., shots=) default (verified: a run with shots=100 and
one PUB carrying 16 produced 100 and 16 shots respectively).
5.2 Broadcasting rules (Estimator)
Observables array and parameter-value sets broadcast like NumPy arrays:
# verified locally
import numpy as np
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorEstimator
theta = Parameter("t")
pqc = QuantumCircuit(1)
pqc.ry(theta, 0)
params = np.linspace(0, np.pi, 5).reshape(5, 1) # 5 sets of 1 param -> shape (5,)
obs = [["Z"], ["X"]] # observables shape (2, 1)
result = StatevectorEstimator().run([(pqc, obs, params)]).result()[0]
result.data.evs.shape # (2, 5) <- (2,1) broadcast against (5,)Rules of thumb: shapes must be NumPy-broadcast-compatible; (2,1) × (5,) →
(2,5); equal shapes pair elementwise; a single observable against N
parameter sets → shape (N,).
6. Local testing (no account needed)
| Tool | Import | Noise | Use for |
|---|---|---|---|
StatevectorSampler / StatevectorEstimator |
qiskit.primitives |
ideal (exact) | algorithm logic, reference values |
Fake backends (FakeManilaV2, FakeSherbrooke, ...) |
qiskit_ibm_runtime.fake_provider |
QPU-snapshot noise model, real coupling map & basis gates | transpiler + realistic noisy runs |
AerSimulator (also AerSimulator.from_backend(fake)) |
qiskit_aer |
none, or custom/imported noise model | large/fast simulation, method control |
# verified locally — ideal reference primitives
from qiskit.primitives import StatevectorSampler, StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
job = StatevectorSampler().run([qc], shots=1024)
counts = job.result()[0].data.meas.get_counts() # ~50/50 '00'/'11' for a Bell pair
bell_no_meas = QuantumCircuit(2); bell_no_meas.h(0); bell_no_meas.cx(0, 1)
ev = StatevectorEstimator().run([(bell_no_meas, SparsePauliOp("ZZ"))]).result()[0].data.evs
# ev ≈ 1.0# verified locally — Runtime primitives in local testing mode
from qiskit_ibm_runtime import SamplerV2, EstimatorV2
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
from qiskit_aer import AerSimulator
backend = FakeManilaV2()
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_qc = pm.run(qc)
sampler = SamplerV2(mode=backend) # local testing mode: mode = fake backend
counts = sampler.run([isa_qc], shots=2048).result()[0].data.meas.get_counts()
# noisy: mostly '00'/'11' plus small '01'/'10' leakage
aer = AerSimulator() # ideal Aer
noisy_aer = AerSimulator.from_backend(backend) # Aer with the fake backend's noise
sampler = SamplerV2(mode=noisy_aer)Local-testing-mode facts (from the docs):
- Available since qiskit-ibm-runtime 0.22; same code, swap
mode=to a real backend when you're ready for hardware. - All runtime options are accepted but everything except
shotsis ignored on a local simulator. - Fake backends use Aer under the hood if installed; snapshot noise is applied automatically.
Common exam traps
StatevectorSampler/StatevectorEstimatorlive inqiskit.primitives(the SDK), notqiskit_ibm_runtime.- Fake backends live in
qiskit_ibm_runtime.fake_provider.- Fake backends still require ISA circuits (they enforce the target).
- Ideal
StatevectorSampleraccepts any circuit — no ISA requirement, because there is no target.- Sampler needs measurements; Estimator circuits must NOT be measured on the observable qubits (use the un-measured circuit + observable).
7. Coding labs — predict, then run
Use /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python.
Lab 1 — "The noisy GHZ bet" (FakeManilaV2)
Build a 3-qubit GHZ circuit (h(0), cx(0,1), cx(1,2), measure_all()).
Transpile it for FakeManilaV2 with a preset pass manager (opt level 1) and
run it through SamplerV2(mode=backend) with 4000 shots.
Predict before running:
- Which two bitstrings dominate, and roughly what fraction of the 4000 shots do they keep combined on this noisy 5-qubit snapshot? (Pick: >99%, ~80-90%, ~50%.)
- Will the raw (untranspiled) circuit run if you pass it directly? Why not?
Lab 2 — "Sweep and guess the curve" (StatevectorEstimator)
Create a 1-qubit circuit with ry(theta, 0). Sweep theta over 9 evenly
spaced values in [0, 2π] in a SINGLE PUB
((circuit, "Z", params) with params.shape == (9, 1)), run on
StatevectorEstimator.
Predict before running:
- The exact mathematical function the 9
evsvalues trace. - The values at θ = 0, π/2, π.
- The shape of
result[0].data.evs. Then extend: request observables[["Z"], ["X"]]in the same PUB and predict the newevsshape and the X-row values.
Lab 3 — "Batch rehearsal, offline" (FakeSherbrooke)
You can't open a real Batch without an account, so rehearse the pattern:
transpile a 2-qubit Bell and a 3-qubit GHZ for FakeSherbrooke (127 qubits),
then submit them as two independent SamplerV2(mode=backend) jobs in a
loop, collecting job objects first and calling .result() afterwards —
exactly the batch submission shape.
Predict before running:
FakeSherbrooke's 2-qubit basis gate — is itcx? (Checkbackend.target.operation_names.)- Which real execution mode this pattern maps to, and why session would be the wrong (more expensive) choice.
- Whether the two jobs share any state or ordering guarantee in a real batch.
Solutions in solution-bank.md.