SDK: backends and the Qiskit bridge
Reference for qsolve.backends and qsolve.bridge: targets and get_backend, Backend and AsyncBackend, AerBackend, FakeHardwareBackend, IBMBackend, RemoteBackend, QsolveBackend, QsolveSampler and QsolveEstimator.
Updated 13 November 2026 View as markdown
On this page
How circuits get from your Qiskit code to a simulator, a noise model or a quantum computer. In your workflow you only touch the bridge objects in ctx (ctx.backend, ctx.sampler, ctx.estimator); the backends behind them are the platform's. Targets and what they cost are explained in Backends and targets. Back to the SDK reference.
Targets
| Target string | Backend class | target / provider / device attributes |
Notes |
|---|---|---|---|
sim-cpu |
AerBackend(gpu=False) |
sim-cpu / aer / aer-cpu |
Noise-free Qiskit Aer, up to 26 qubits. |
sim-gpu |
AerBackend(gpu=True) |
sim-gpu / aer / aer-gpu |
Needs qiskit-aer-gpu; up to 30 qubits. |
fake:<device>[@scale] |
FakeHardwareBackend |
qpu / fake / fake_<device>[@scale] |
An IBM device's published noise on Aer; needs the ibm extra; free. |
ibm:<device> |
IBMBackend |
qpu / ibm / <device> |
Real IBM hardware; needs the ibm extra and QSOLVE_IBM_TOKEN. |
| (platform only) | RemoteBackend |
whatever the executor reports | Used inside the sandbox; see below. |
qsolve.backends
from qsolve.backends import (AerBackend, AsyncBackend, Backend, ProviderJobFailed,
SampleResult, get_backend)
The package __all__ holds exactly those six names. FakeHardwareBackend, IBMBackend and RemoteBackend live in the submodules qsolve.backends.fake, .ibm and .remote and are imported lazily so the optional dependencies load only when needed.
get_backend
get_backend(target: str) -> Backend
Resolves a target string to a backend object. Called by run_workflow when you pass no backend. It takes no other arguments: an unknown keyword argument raises TypeError, and an unknown or malformed target raises ValueError (for example a non-numeric noise scale in fake:fez@fast).
| Rule (checked in this order) | Result |
|---|---|
Environment variable QSOLVE_EXECUTOR_ADDR is set |
RemoteBackend(), whatever target says. On the platform the workflow only ever gets the credential-free remote backend; the executor decides the real device. |
sim-cpu |
AerBackend(gpu=False) |
sim-gpu |
AerBackend(gpu=True) |
starts with ibm: |
IBMBackend(device=<text after the colon>) |
starts with fake: |
FakeHardwareBackend(device, scale), parsing device[@scale] with scale = float(...), default 1.0 |
| anything else | ValueError("Unknown target '...'. Use sim-cpu, sim-gpu, ibm:<device> or fake:<device>[@scale].") |
Other exceptions: RuntimeError when the ibm extra is missing or QSOLVE_IBM_TOKEN is not set; ValueError for an unknown fake device, a scale outside 1 to 10, or a scale that is not a number. run_workflow turns ValueError, RuntimeError and ImportError from this call into WorkflowError.
SampleResult
@dataclass
class SampleResult:
counts: dict[str, int]
shots: int
exec_time_s: float = 0.0
metadata: dict[str, Any] = field(default_factory=dict)
What a backend returns for one circuit. counts maps bit strings to counts in Qiskit order (qubit 0 is the rightmost character; spaces from multiple registers are tolerated by consumers). metadata varies by backend:
| Key | From | Meaning |
|---|---|---|
dispatch |
every run through the bridge | Index of the recorded circuit; cite it in ctx.report_mitigation. Added by QsolveBackend.dispatch. |
readout |
fake, ibm |
Per measured bit, [P(read 1 given 0), P(read 0 given 1)] of the physical qubit it came from, or None when the device does not report it. |
two_qubit_gates |
fake, ibm |
Two-qubit gates in the transpiled circuit. |
physical_qubits |
fake, ibm |
Physical qubits the transpiled circuit used. |
device_circuit |
fake, ibm |
The transpiled circuit as drawable ops (circuit_ops), for the first four distinct circuits of a batch, each of at most 5,000 operations. |
job_id, batch_index, batch_size, quantum_seconds |
ibm |
The provider job; quantum_seconds (the job's billed quantum time) appears on the first circuit of a batch, 0 on the others. |
MAX_BATCH and sample_all
MAX_BATCH = 300 (in qsolve.backends.base): circuits per batch, bounded by provider job limits and the executor's message size.
sample_all(backend: Backend, circuits: Sequence[QuantumCircuit], shots: int,
seeds: Sequence[int]) -> list[SampleResult]
backend.sample_batch in chunks of MAX_BATCH. For code that talks to a Backend directly, with circuits that do not depend on each other's results.
Backend
class Backend(ABC):
target: str # "sim-cpu" | "sim-gpu" | "qpu"
provider: str
device: str
@abstractmethod
def sample(self, circuit: QuantumCircuit, shots: int, seed: int) -> SampleResult
def sample_batch(self, circuits: Sequence[QuantumCircuit], shots: int,
seeds: Sequence[int]) -> list[SampleResult]
def calibration_snapshot(self) -> dict[str, Any]
@property
def max_qubits(self) -> int
Executes measured circuits and returns counts. Workflows never touch a Backend; the harness does. Subclass it only to test the harness with your own fake.
| Member | Meaning |
|---|---|
target, provider, device |
Class or instance attributes a subclass must set. target decides source in the fidelity measurement: anything other than qpu is an ideal simulator. |
sample(circuit, shots, seed) |
Abstract. Run one circuit. seed seeds simulators; hardware ignores it. |
sample_batch(circuits, shots, seeds) |
Run several independent circuits, each with its own seed. Default: one sample call per circuit (a simulator then gives exactly the counts of separate calls). Raises ValueError("one seed per circuit") when the lengths differ. Overridden by backends that can send a batch as one job. |
calibration_snapshot() |
Device facts stored in provenance.calibration_snapshot. Default {}. |
max_qubits |
Largest circuit accepted. Default 30. The bridge sizes its Qiskit Target with it. |
AsyncBackend
class AsyncBackend(Backend):
batches: bool = False
def submit(self, circuit, shots, seed) -> str # abstract
def collect(self, job_id: str) -> SampleResult # abstract
def cancel(self, job_id: str) -> None # default: does nothing
def submit_batch(self, circuits, shots, seeds) -> str # default: NotImplementedError
def collect_batch(self, job_id: str) -> list[SampleResult] # default: NotImplementedError
A backend whose jobs live at the provider and can be collected later by id (QPUs). The platform records the job id between submit and collect, so a run interrupted while waiting in a hardware queue re-attaches to the same job instead of paying twice. sample is collect(submit(...)). sample_batch uses submit_batch and collect_batch when batches is true (one provider job, one queue wait) and otherwise falls back to one job per circuit. submit and collect are abstract; the batch pair must be implemented when batches is true. cancel should cancel a job still waiting at the provider.
ProviderJobFailed
class ProviderJobFailed(RuntimeError). The provider finished a job without a result (failed or cancelled on their side). Resubmitting is the right response; any other exception may be transient. Raised by IBMBackend.collect_batch when IBM reports ERROR or CANCELLED.
AerBackend
AerBackend(gpu: bool = False)
Local Qiskit Aer simulator. gpu=True needs qiskit-aer-gpu and sets target="sim-gpu", device="aer-gpu"; otherwise sim-cpu and aer-cpu. provider is aer. max_qubits is 30 on GPU, 26 on CPU.
| Method | Behaviour |
|---|---|
sample(circuit, shots, seed) |
Simulates one circuit with seed_simulator=seed inside qsolve.sim.simulator_threads(), so every core the run was given is used. Returns counts and wall-clock seconds. The same seed gives the same counts at any thread count. |
runnable(circuit, seed=0) |
Returns circuit itself when every gate in it is native to Aer (or is barrier or measure), else the circuit transpiled for Aer with seed_transpiler=seed. Transpiling a small circuit costs far more than simulating it, so it is skipped when possible. |
FakeHardwareBackend
from qsolve.backends.fake import FakeHardwareBackend
FakeHardwareBackend(device: str = "fez", scale: float = 1.0)
A real device's published noise, simulated: the free rehearsal for hardware runs. target="qpu", provider="fake", device="fake_<name>" with "@<scale:g>" appended when scale != 1. Needs the ibm extra.
| Parameter | Meaning |
|---|---|
device |
Name of a fake IBM device: fez, marrakesh, torino, ... The class Fake<Name> is looked up in qiskit_ibm_runtime.fake_provider. ValueError("no fake device 'x' (e.g. fez, marrakesh, torino)") if absent. |
scale |
Multiplier on every error rate (gate, readout, preparation errors, capped at 0.5 for readout and 0.75 for gates); T1 and T2 are divided by it. Must be from 1 to 10, else ValueError("noise scale must be between 1 and 10"). fez@1.6 is a pessimistic rehearsal: a real device has tended to be worse than its own published model (1.6 is an empirical rule of thumb, not a fitted constant). |
sample_batch transpiles the circuits exactly as for the device (transpile_best) and runs them as one Aer job with the noise model. Aer takes one seed per job, so for a batch every circuit's seed is mixed into it (a hash of the whole list): changing any one seed changes the job, and the same seeds always give the same counts. A single circuit keeps its own seed. Each result carries readout, two_qubit_gates, physical_qubits and (for a few circuits) device_circuit metadata, the same as IBMBackend, so workflows exercise their hardware path end to end. sample is a batch of one. calibration_snapshot() returns {"backend": device, "noise_scale": scale}. max_qubits is the simulated device's qubit count. Building the noise model takes several seconds for a 156-qubit device.
IBMBackend
from qsolve.backends.ibm import IBMBackend
IBMBackend(device: str, token: str | None = None, instance: str | None = None)
IBM Quantum hardware through qiskit-ibm-runtime. An AsyncBackend with batches = True. Credentials come from the environment, never from workflow code.
| Parameter | Meaning |
|---|---|
device |
IBM backend name, for example ibm_fez. |
token |
Defaults to QSOLVE_IBM_TOKEN. RuntimeError("QSOLVE_IBM_TOKEN is not set") if neither exists. |
instance |
Defaults to QSOLVE_IBM_INSTANCE. |
RuntimeError if the ibm extra is missing. The constructor connects (channel ibm_quantum_platform) and looks up the device, so it needs network access.
| Method | Behaviour |
|---|---|
submit_batch(circuits, shots, seeds) |
Transpiles with transpile_best (keeping the cheapest of four tries), sends one SamplerV2 job with dynamical decoupling (XpXm) and gate twirling on (measurement twirling off, so readout errors stay as calibrated), and caps the job's quantum time with max_execution_seconds. Returns the IBM job id. Costs your IBM quota. |
collect_batch(job_id) |
Waits for the job; one SampleResult per circuit in submission order, metadata as in the table above. exec_time_s is the wall wait divided by the batch size. Raises ProviderJobFailed when IBM ended the job as ERROR or CANCELLED, other exceptions as they are. |
submit, collect |
One-circuit versions of the above. |
cancel(job_id) |
Cancels a job that is not yet DONE, ERROR or CANCELLED. |
calibration_snapshot() |
{"backend": device, "n_qubits": n}. |
max_qubits |
The device's qubit count. |
Module constants in qsolve.backends.ibm: S_PER_JOB = 2.4 and S_PER_SHOT = 0.00023, an empirical fit to billed quantum time on ibm_fez (IBM can change its billing; re-fit if estimates drift from the bill), and
max_execution_seconds(n_circuits: int, shots: int) -> int
which returns ceil(10 + 3 * (S_PER_JOB + S_PER_SHOT * n_circuits * shots)): three times the expected bill plus 10 s, so a job that somehow needs more is stopped by IBM instead of draining the account.
Hardware helpers (qsolve.backends.hardware)
Shared by the fake and IBM backends; not needed by workflows.
| Name | Meaning |
|---|---|
TRANSPILE_TRIES = 4 |
Seeds tried per circuit. |
transpile_best(circuits, backend, seed, tries=4) -> list[QuantumCircuit] |
Transpile at optimisation level 3 (noise-aware layout) with tries seeds and keep, per circuit, the result with the fewest two-qubit gates, then the shallowest. Deterministic. Circuits that differ only in rotation angles are compiled once as a template and then given their angles, so copies sent to split shots share one result. |
two_qubit_count(circuit), two_qubit_counts(circuits) |
Two-qubit operations, barriers excluded. |
measured_qubits(circuit) -> dict[int, int] |
Classical bit to physical qubit, from the measurements of a transpiled circuit. |
readout_errors(circuit, properties) -> list[list[float]] | None |
[[P(1|0), P(0|1)], ...] per classical bit, or None when unavailable. |
device_metadata(compiled) -> list[dict] |
Per circuit two_qubit_gates, physical_qubits, and for the first DEVICE_CIRCUITS = 4 distinct circuits (of at most DEVICE_CIRCUIT_MAX_OPS = 5000 operations) the transpiled circuit as device_circuit. |
RemoteBackend and the executor (internal)
qsolve.backends.remote is the protocol by which a sandboxed workflow reaches a backend without holding any credentials. The project's contributing notes list it as internal and subject to change, so this is a summary, not a contract.
Inside the sandbox there is no network and no provider secret. RemoteBackend(addr=None, token=None) talks to the platform's executor over a local socket: addr defaults to QSOLVE_EXECUTOR_ADDR (unix:<path> or tcp:<host>:<port>), token to QSOLVE_EXECUTOR_TOKEN. It asks the executor for info and copies its target, provider, device, max_qubits and calibration. sample and sample_batch send circuits as base64 QPY, one JSON object per line, in chunks of MAX_BATCH; replies larger than MAX_LINE_BYTES (8 MiB) are refused. Executor errors become RuntimeError("executor error: ..."). The executor owns credentials and enforces credit holds, shot and qubit caps and writes the provenance.
serve_executor(backend, host="127.0.0.1", port=0, *, address=None, max_shots=100_000, max_qubits=32, token=None) -> (server, address) is the reference executor the tests use: it serves backend on unix:<path> or tcp:<host>:<port> and returns the server and the resolved address. The platform wraps it with metering. A request with shots outside 1..max_shots, a circuit above the qubit cap or a wrong token is refused.
qsolve.bridge
from qsolve.bridge import DEFAULT_SHOTS, QsolveBackend, QsolveEstimator, QsolveSampler
Qiskit-native execution. A workflow written in plain Qiskit runs through these unchanged. Behind them is whatever target the run was given. Circuits are sent as the workflow built them (the platform transpiles for the device); every dispatch is recorded by the run's Recorder (Metering and cost). DEFAULT_SHOTS = 1024.
QsolveBackend
QsolveBackend(inner: Backend, recorder: Recorder, *, seed: int = 0, allow_quantum: bool = True)
A Qiskit BackendV2 over a Qsolve target. It is ctx.backend. The harness builds it; you use it.
| Parameter | Meaning |
|---|---|
inner |
The Qsolve Backend to run on. |
recorder |
Where every dispatch is recorded. |
seed |
Base seed. The n-th circuit sent through this object gets seed seed + n. |
allow_quantum |
When false, dispatch (and so run, the sampler and the estimator) raises QuantumNotAllowed. The classical methods' context is built this way. |
Attributes: name is qsolve-<inner.device>, backend_version is "1", inner, recorder, allow_quantum. target is a Qiskit Target with a generic gate set (id x y z h s sdg t tdg sx sxdg rx ry rz p u cx cy cz ch swap crx cry crz cp rxx ryy rzz ecr ccx) sized to inner.max_qubits. max_circuits is None (batches are split internally). Default options: shots=1024 and seed_simulator=None. With seed_simulator set (as an option of run, or with set_options), circuit k of that call is seeded seed_simulator + k, so a call is reproducible whatever ran before it; left None, seeds continue from seed plus a running count.
dispatch
dispatch(self, circuits: Sequence[QuantumCircuit], shots: int) -> list[SampleResult]
The one place circuits leave the workflow. Splits into batches of MAX_BATCH, copies each circuit (so what is recorded is what is sent, even if your code edits its circuit afterwards), assigns seeds, calls inner.sample_batch, records circuit, shots, counts, metadata and time in the Recorder, and adds metadata["dispatch"] (the recorded index) to each result. Thread-safe. Raises QuantumNotAllowed when allow_quantum is false. Called by run, by the sampler and by the estimator; you rarely call it directly.
run
run(self, run_input: QuantumCircuit | Sequence[QuantumCircuit], **options) -> JobV1
Qiskit's BackendV2.run. run_input is one circuit or a sequence. The only option read is shots (default 1024). Returns an already-finished job (runs are synchronous from the workflow's point of view) whose result() is a Qiskit Result with counts per circuit, so backend.run(qc, shots=2000).result().get_counts() works as usual.
qc = QuantumCircuit(2); qc.h(0); qc.cx(0, 1); qc.measure_all()
counts = ctx.backend.run(qc, shots=2000).result().get_counts()
QsolveSampler
QsolveSampler(backend: QsolveBackend, *, default_shots: int = 1024)
A SamplerV2 over a QsolveBackend. It is ctx.sampler.
run(self, pubs: Iterable[SamplerPubLike], *, shots: int | None = None) -> BasePrimitiveJob
| Parameter | Meaning |
|---|---|
pubs |
Sampler pubs: a circuit, or (circuit, parameter_values, shots) tuples. A shot count inside a pub wins over shots. |
shots |
Default shots for pubs that give none; if also None, default_shots is used. |
Returns a finished job; .result() is a PrimitiveResult with one pub result per pub. Each pub result has a data field per classical register of the circuit (so measure_all() gives data.meas), each a BitArray, and metadata with shots, circuit_metadata and qsolve, a list with one entry per bound circuit (in flattened parameter order) holding the backend's metadata, including dispatch and, on qpu targets, readout and two_qubit_gates. All circuits of all pubs with the same shot count go to the backend as one batch (on a QPU: one job, one queue wait). A result always holds exactly the requested number of shots: when a device rounds the count up (5000 asked, 5024 returned) a random subset of the requested size is kept; fewer than requested raises ValueError.
counts = ctx.sampler.run([(measured, theta, shots)]).result()[0].data.meas.get_counts()
Gotchas. A circuit must contain measurements. Bit strings are in Qiskit order (clbit 0 on the right), so variable 0 is the last character; reverse before decoding. The result is always synchronous.
QsolveEstimator
QsolveEstimator(backend: QsolveBackend, *, default_precision: float = 0.015625)
An EstimatorV2 over a QsolveBackend. It is ctx.estimator. It measures each group of qubit-wise commuting Pauli terms of an observable in its own basis and combines the results. No error mitigation is applied.
run(self, pubs: Iterable[EstimatorPubLike], *, precision: float | None = None) -> BasePrimitiveJob
| Parameter | Meaning |
|---|---|
pubs |
Estimator pubs: (circuit, observables, parameter_values). |
precision |
Target standard error of each value. Defaults to default_precision (0.015625, which is 4096 shots per measurement group). A pub's own precision wins. |
For each value the shots per group are ceil(1 / precision**2). Returns a finished job; each pub result holds data.evs and data.stds (arrays of the broadcast shape) and metadata["target_precision"]. Cost note: every parameter set and every observable is a separate dispatch with one circuit per basis group, so many observables or tight precision quickly mean many circuits and shots; the run is billed for what is sent, and estimate() should account for it.