Skip to content
Kartikey Purohit
← Course overview

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 account

Key save_account facts:

  • Credentials are written to ~/.qiskit/qiskit-ibm.json.
  • overwrite=False is the default — re-saving the same account name without overwrite=True raises an error.
  • If no instance is given, an appropriate instance is auto-selected; you can steer the selection with region ("us-east" / "eu-de"), plans_preference (e.g. ["open"]), and tags — all three are ignored if instance is specified.
  • You can also pass token= directly to QiskitRuntimeService(...) 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 backends

least_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 flow

The 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 backend

Verified 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

  1. The first job in a session/batch waits in the normal queue — no queue advantage for job #1.
  2. When it starts, the maximum TTL timer starts (max_time parameter; default 8 hours = 28800 s) and never pauses.
  3. 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.
  4. 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
  • Session as a context manager auto-closes; you do NOT call close() inside a with block.
  • 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_time is.
  • 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 completes

One 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 shots is ignored on a local simulator.
  • Fake backends use Aer under the hood if installed; snapshot noise is applied automatically.
Common exam traps
  • StatevectorSampler/StatevectorEstimator live in qiskit.primitives (the SDK), not qiskit_ibm_runtime.
  • Fake backends live in qiskit_ibm_runtime.fake_provider.
  • Fake backends still require ISA circuits (they enforce the target).
  • Ideal StatevectorSampler accepts 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:

  1. 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%.)
  2. 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:

  1. The exact mathematical function the 9 evs values trace.
  2. The values at θ = 0, π/2, π.
  3. The shape of result[0].data.evs. Then extend: request observables [["Z"], ["X"]] in the same PUB and predict the new evs shape 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:

  1. FakeSherbrooke's 2-qubit basis gate — is it cx? (Check backend.target.operation_names.)
  2. Which real execution mode this pattern maps to, and why session would be the wrong (more expensive) choice.
  3. Whether the two jobs share any state or ordering guarantee in a real batch.

Solutions in solution-bank.md.