Superfermion logo
Guides

QPU Providers

Run circuits on real quantum hardware: IBM Quantum, IonQ, AWS Braket, and OpenQuantum.

Superfermion uses a protocol-based provider model. Any object satisfying the DeviceExecutor protocol works with sf.run() — no registries, no magic strings.

Provider Model

sf.run(circuit, device=provider("device_name"))
                                │
                    ┌───────────┼───────────┐
                    ▼           ▼           ▼
               IBMDevice   IonQDevice   BraketDevice

Each provider is instantiated with credentials, then called with a device name to produce a DeviceExecutor.

IBM Quantum

pip install "superfermion[qpu]"
from superfermion.devices.ibm import IBMDevice
import superfermion as sf

# Connect — token is optional; IBMDevice() also reads QISKIT_IBM_TOKEN
ibm = IBMDevice(token="your-ibm-token")

# List hardware available on your account
for dev in ibm.list_devices():
    print(f"{dev.name}: {dev.n_qubits} qubits, {dev.status}")

# Run on real hardware — no measure gates required
qc = sf.Circuit(2).h(0).cnot(0, 1)
result = sf.run(qc, device=ibm("ibm_fez"), shots=8192)

print(result.counts)     # SF q0-last bitstrings, same convention as local runs
print(result.metadata)   # {'backend': 'ibm_fez', 'provider': 'ibm'}

token is optional — if omitted, IBMDevice() reads the QISKIT_IBM_TOKEN environment variable instead.

Automatic Measurements

SF circuits carry measurement implicitly (Sampler-style semantics), but IBM's SamplerV2 rejects circuits without explicit measurement instructions (runtime error 1515). The adapter therefore appends a full Z-basis measure_all() to the converted Qiskit circuit whenever it contains no measure gates, so plain circuits run on real hardware unchanged. Explicit circuit.measure(q) / circuit.measure_all() gates are respected and never duplicated.

Counts Convention (q0-last)

result.counts follows SF's documented q0-last convention — int(key, 2) bit q holds qubit q, so qubit 0 is the rightmost character (x(0) on 2 qubits reports '01') — matching the local "cpu" sampler. IBM's SamplerV2 returns q0-first bitstrings (position i holds SF qubit i; verified empirically, x(0) on ibm_fez returns '10'), so the adapter reverses every key of the measured width before building the RunResult. All counts consumers — RunResult.expectation, parameter_shift_grad, readout_correction — therefore behave identically for QPU and simulator results, and results from different providers can be compared directly.

Parameter-Shift Gradients on Hardware

from superfermion.observables import SparsePauliOp
from superfermion.qml.gradient.parameter_shift import parameter_shift_grad

theta = sf.param("theta")
circ = sf.Circuit(2).ry(theta, 0).cx(0, 1)
obs = SparsePauliOp.from_string("ZI", n_qubits=2)   # Z on qubit 0

grad = parameter_shift_grad(circ, obs, {"theta": 0.8},
                            device=ibm("ibm_fez"), shots=2048)
print(grad)   # analytic value: -sin(0.8) = -0.7174

Verified on real IBM QPUs (ibm_marrakesh, ibm_fez, September 2026): one-qubit and entangled two-qubit parameter-shift gradients match the analytic value within counting statistics at 2048 shots per shifted run. A two-qubit case with an idle second qubit failed before the normalization fix (Ry(0.8, 0), obs ZI: measured +0.0000 vs analytic −0.7174, z = 64 — raw SamplerV2 keys were stored q0-first and misread as q0-last); after the adapter normalization the same case measures −0.72 ± 0.01 (z ≈ 0) on the same backend. Expect small residual attenuation from readout error: measured ≈ analytic × (1 − E), where E = p(1|0) + p(0|1) for the measured qubit (≈1–3% on current devices, from live calibration).

Error Mitigation

from superfermion.mitigation import readout_correction, zne

# Readout-error correction: M[i][j] = P(measure i | true j)
corrected = readout_correction(result.counts, calibration_matrix=M)

# Zero-noise extrapolation: observable_fn maps a statevector to a scalar
value = zne(qc, observable_fn=my_observable_fn, scale_factors=[1, 2, 3])

Transpilation & mitigation: the adapter transpiles with Qiskit's preset pass manager at optimization_level=3 (the executor default) and applies no error mitigation on the device path. To request a different compile level, pass it through sf.run() — extra kwargs reach the executor unchanged, e.g. sf.run(qc, device=ibm("ibm_fez"), shots=8192, optimization_level=1). readout_correction and zne are manual helpers: readout_correction takes the (already q0-last) counts dict plus an optional calibration matrix; zne folds the circuit at increasing noise scales and extrapolates an observable_fn.

IonQ

pip install "superfermion[qpu]"
from superfermion.devices.ionq import IonQDevice
import superfermion as sf

# Connect
ionq = IonQDevice(api_key="your-ionq-key")

# Run on trapped-ion hardware
qc = sf.Circuit(3).h(0).cnot(0, 1).cnot(1, 2)
result = sf.run(qc, device=ionq("aria-1"), shots=1000)

print(result.counts)

IonQ devices have native all-to-all connectivity — no SWAP routing needed. The compiler skips qubit mapping automatically.

AWS Braket

pip install "superfermion[qpu]"
from superfermion.devices.braket import BraketDevice
import superfermion as sf

# Connect (uses AWS credentials from environment or ~/.aws)
braket = BraketDevice(s3_bucket="my-braket-results")

# Run on AWS hardware
qc = sf.Circuit(2).h(0).cnot(0, 1).measure_all()
result = sf.run(qc, device=braket("sv1"), shots=1000)  # simulator
# result = sf.run(qc, device=braket("rigetti_aspen_m3"), shots=1000)  # hardware

print(result.counts)

Available Braket Devices

# List available devices
devices = braket.list_devices()
for d in devices:
    print(f"{d.name}: {d.n_qubits} qubits, {d.status}")

# Simulators
result = sf.run(qc, device=braket("sv1"))    # statevector simulator
result = sf.run(qc, device=braket("tn1"))    # tensor network simulator
result = sf.run(qc, device=braket("dm1"))    # density matrix simulator

# Hardware (requires appropriate AWS region access)
# braket("rigetti_aspen_m3")   # Rigetti
# braket("ionq_harmony")       # IonQ via Braket
# braket("oqc_lucy")           # Oxford Quantum Circuits

OpenQuantum

from superfermion.devices.openquantum import OpenQuantumDevice
import superfermion as sf

# Connect to any OpenQuantum-compatible device
oq = OpenQuantumDevice(endpoint="https://api.example.com", api_key="...")
result = sf.run(qc, device=oq("device_01"), shots=1000)

Local Simulation vs QPU

Featuredevice="cpu"Provider Device
No credentials✅—
Instant execution✅— (queue wait)
Shot limitUnlimitedPer-device
CostFreePer-shot pricing
Noise modelOptionalReal hardware noise
Circuit constraintsNoneBasis gates, topology

Experiment Tracking with QPU

Track QPU jobs alongside local runs:

with sf.experiment("qpu-benchmark") as tracker:
    # Local baseline
    sf.run(qc, device="cpu", shots=10000)

    # QPU runs
    sf.run(qc, device=ibm("ibm_fez"), shots=8192)
    sf.run(qc, device=ionq("aria-1"), shots=1000)

# All runs logged in ~/.superfermion/runs/qpu-benchmark/
for run in tracker.runs:
    print(f"{run['device']}: {run['counts']}")

Adding a New Provider

Providers implement the DeviceExecutor protocol — a class with an execute() and a capabilities() method. No registration required:

import superfermion as sf
from superfermion.devices import DeviceExecutor, DeviceCapabilities

class MyCustomDevice:
    """Any class with execute() + capabilities() satisfies the protocol."""
    def execute(self, circuit, shots, **kwargs):
        # Simulate the submitted circuit locally and return a RunResult
        return sf.run(circuit, device="cpu", shots=shots)

    def capabilities(self):
        return DeviceCapabilities()

# Use directly — no registration needed
qc = sf.Circuit(2).h(0).cnot(0, 1)
my_device = MyCustomDevice()
assert isinstance(my_device, DeviceExecutor)   # structural check
result = sf.run(qc, device=my_device, shots=1000)
print(result.counts)

The protocol requires two methods: execute(circuit, shots) -> RunResult and capabilities() -> DeviceCapabilities. sf.run() validates the object against the protocol and calls execute() to run the circuit.

On this page