Qiskit Backend Integration¶
The mqt.core.plugins.qiskit module provides a Qiskit
BackendV2-compatible interface to QDMI devices via
the MQT Core QDMI bindings. This integration lets you execute Qiskit circuits on
QDMI devices with a standard Qiskit workflow.
Installation¶
Install MQT Core with Qiskit support:
uv pip install "mqt-core[qiskit]"
python -m pip install "mqt-core[qiskit]"
Quickstart¶
1from mqt.core.plugins.qiskit import QDMIBackend
2from qiskit import QuantumCircuit
3
4# Open the registered DDSIM device by its stable ID
5backend = QDMIBackend.from_device_id("mqt.ddsim.default")
6
7# Create a simple circuit
8qc = QuantumCircuit(2)
9qc.h(0)
10qc.cx(0, 1)
11qc.measure_all()
12
13# Execute the circuit
14job = backend.run(qc, shots=1024)
15result = job.result()
16counts = result.get_counts()
17
18assert sum(counts.values()) == 1024
19assert set(counts) <= {"00", "11"}
20print(f"Results: {counts}")
Results: {'00': 520, '11': 504}
Provider and Device Discovery¶
Using the Provider¶
The QDMIProvider discovers
client-visible QDMI devices. Use it when an application must enumerate backends.
1from mqt.core.plugins.qiskit import QDMIProvider
2
3# Create a provider
4provider = QDMIProvider()
5
6# List all available backends
7backends = provider.backends()
8for backend in backends:
9 print(f"{backend.name}: {backend.target.num_qubits} qubits")
MQT Core DDSIM QDMI Device: 65535 qubits
Getting a Specific Backend¶
1# Open a backend directly by stable device ID
2from mqt.core.plugins.qiskit import QDMIBackend
3
4backend = QDMIBackend.from_device_id("mqt.ddsim.default")
5print(f"Backend: {backend.name}")
6print(f"Qubits: {backend.target.num_qubits}")
Backend: MQT Core DDSIM QDMI Device
Qubits: 65535
Optional session keywords configure this fresh device session through the MQT
Core QDMI driver. Their names and value types are described by
mqt.core.typing.QDMISessionParameters. The selected Driver defines
their meaning and precedence:
backend = QDMIBackend.from_device_id(
"provider.device",
token="access-token",
custom1="provider-specific-value",
)
Filtering Backends¶
# Filter backends by name substring
filtered_qdmi = provider.backends(name="QDMI") # Matches all backends with "QDMI" in name
filtered_ddsim = provider.backends(name="DDSIM") # Matches "MQT Core DDSIM QDMI Device"
# Filter by full name also works
exact = provider.backends(name="MQT Core DDSIM QDMI Device")
Authentication¶
QDMIBackend.from_device_id and QDMIProvider.get_backend_by_device_id accept
the device-session parameters in
mqt.core.typing.QDMISessionParameters, including base_url,
token, and auth_file. The device validates these values and can also use
environment variables or a platform credential provider.
QDMIProvider.backends() uses a fresh session without explicit authentication
parameters.
Device Capabilities and Target¶
The backend automatically introspects the QDMI device and constructs a Qiskit
Target object describing device capabilities.
1# Access device properties via the Target
2print(f"Number of qubits: {backend.target.num_qubits}")
3print(f"Supported operations: {backend.target.operation_names}")
4
5# Check coupling map (if device has limited connectivity)
6coupling_map = backend.target.build_coupling_map()
7if coupling_map:
8 print(f"Coupling map: {coupling_map}")
Number of qubits: 65535
Supported operations: dict_keys(['global_phase', 'id', 'x', 'cx', 'ccx', 'mcx', 'y', 'cy', 'z', 'cz', 'ccz', 'h', 'ch', 's', 'cs', 'sdg', 'csdg', 't', 'tdg', 'sx', 'csx', 'sxdg', 'r', 'rx', 'crx', 'ry', 'cry', 'rz', 'crz', 'p', 'cp', 'mcphase', 'u1', 'cu1', 'u2', 'u', 'cu', 'swap', 'cswap', 'iswap', 'dcx', 'ecr', 'rxx', 'ryy', 'rzz', 'rzx', 'xx_minus_yy', 'xx_plus_yy', 'rccx', 'measure', 'reset'])
The backend maps QDMI device operations to corresponding Qiskit gates, including:
Single-qubit Pauli gates:
x,y,z,id/iHadamard:
hPhase gates:
s,sdg,t,tdg,sx,sxdg,p,phase,gphaseRotation gates (parametric):
rx,ry,rz,r/prxUniversal gates (parametric):
u,u1,u2,u3Two-qubit gates:
cx/cnot,cy,cz,ch,cs,csdg,csx,swap,iswap,dcx,ecrTwo-qubit parametric gates:
cp,cu1,cu3,crx,cry,crz,rxx,ryy,rzz,rzx,xx_plus_yy,xx_minus_yyThree-qubit gates:
ccx,ccz,cswap,rccxMulti-controlled gates:
mcx,mcx_gray,mcphase/mcpNon-unitary operations:
reset,measure
Circuit Execution¶
Circuits must meet the following requirements before execution:
All parameters must be bound: Circuits with unbound parameters raise
CircuitValidationErrorOnly supported operations: Operations not supported by the device raise
UnsupportedOperationErrorValid shots value: Must be a non-negative integer
Optional retries¶
Automatic retries are disabled (max_retries=0). To retry confirmed failed jobs
up to three times, opt in for a run:
job = backend.run(qc, shots=1024, max_retries=3)
Use backend.set_options(max_retries=3) to apply this setting to later runs.
Retries create additional executions and can incur charges. Successful jobs are
reused; errors that do not confirm job failure require
explicit recovery.
Parameter Binding¶
The backend supports automatic parameter binding through the parameter_values
argument. You can pass parameter values either as dictionaries or as sequences
of values:
1from qiskit.circuit import Parameter
2
3# Option 1: Bind parameters manually
4theta = Parameter("theta")
5parameterized = QuantumCircuit(1)
6parameterized.ry(theta, 0)
7parameterized.measure_all()
8
9qc_bound = parameterized.assign_parameters({theta: 1.5708})
10job = backend.run(qc_bound, shots=100)
11
12# Option 2: Use parameter_values argument (recommended)
13job = backend.run(parameterized, parameter_values=[{theta: 1.5708}], shots=100)
14
15# For multiple circuits with different parameters
16circuits = [parameterized, parameterized, parameterized]
17param_values = [{theta: 0.5}, {theta: 1.0}, {theta: 1.5}]
18job = backend.run(circuits, parameter_values=param_values, shots=100)
19bound_results = job.result()
20assert len(bound_results.results) == 3
21print([bound_results.get_counts(i) for i in range(3)])
[{'0': 94, '1': 6}, {'0': 84, '1': 16}, {'0': 57, '1': 43}]
Job Handling¶
Job Status¶
The QDMIJob returned by backend.run()
tracks the submitted circuits:
from qiskit.providers import JobStatus
job = backend.run(qc, shots=1024)
# Check job status
status = job.status()
print(f"Job status: {status}")
Retrieving Results¶
Results are lazily fetched when you call result():
# Run the circuit
job = backend.run(qc, shots=1024)
# Get results (waits for completion if needed)
result = job.result()
# Access measurement counts
counts = result.get_counts()
# Access result metadata
exp_result = result.results[0]
print(f"Circuit name: {exp_result.header['name']}")
print(f"Shots: {exp_result.shots}")
print(f"Success: {exp_result.success}")
Recovering a failed batch¶
A submission or result error preserves accepted jobs and successful results. Keep the handle from the exception to inspect and recover the batch:
1from mqt.core.plugins.qiskit import JobExecutionError, JobSubmissionError
2
3try:
4 job = backend.run([qc, qc_bound], shots=1000)
5 result = job.result()
6except (JobSubmissionError, JobExecutionError) as error:
7 job = error.job
8 print(error)
After an interruption, use backend.last_job. It is None if the latest
run() stopped before preparing a batch. Read accepted work without starting
new executions using collect():
1entries = job.collect()
2available = [i for i, entry in enumerate(entries) if entry.result is not None]
3print("Circuits with results:", available)
Circuits with results: [0, 1]
The entries retain each circuit’s attempts, results, and errors. After resolving
a connection or result-read problem, call job.result() again; it reuses
successful results. If needed, use job.submit() for untouched inputs or
job.resubmit([i]) to replace a failed or cancelled attempt, then retrieve the
result again. See QDMIJob for uncertain
submissions and explicit cancellation. Recovery requires the same Python
process.
Multi-Circuit Execution¶
The backend supports both single-circuit and multi-circuit execution. You can submit multiple circuits in a single call. Circuits with the same program format and shot count use one native QDMI job when the device supports that program list. Otherwise, the backend submits independent jobs before waiting for results. Each entry retains its result index and attempt history; recovery replaces only confirmed failed circuits and keeps successful siblings.
For example:
1# Create multiple circuits
2qc1 = QuantumCircuit(2)
3qc1.h(0)
4qc1.cx(0, 1)
5qc1.measure_all()
6
7qc2 = QuantumCircuit(2)
8qc2.x(0)
9qc2.cx(0, 1)
10qc2.measure_all()
11
12qc3 = QuantumCircuit(2)
13qc3.h([0, 1])
14qc3.measure_all()
15
16# Submit all circuits at once
17circuits = [qc1, qc2, qc3]
18job = backend.run(circuits, shots=1000)
19
20# Get aggregated results
21result = job.result()
22
23# Process results for each circuit
24for idx in range(len(circuits)):
25 counts = result.get_counts(idx)
26 print(f"Circuit {idx} results: {counts}")
Circuit 0 results: {'00': 499, '11': 501}
Circuit 1 results: {'11': 1000}
Circuit 2 results: {'00': 226, '01': 267, '10': 264, '11': 243}
Qiskit Primitives¶
Use Qiskit’s BackendSamplerV2 and BackendEstimatorV2. The backend factories construct these native objects with typed keyword options. Qiskit supplies the defaults and validates the options:
1from qiskit.quantum_info import SparsePauliOp
2
3measured_circuit = QuantumCircuit(2)
4measured_circuit.h(0)
5measured_circuit.cx(0, 1)
6measured_circuit.measure_all()
7circuit = measured_circuit.remove_final_measurements(inplace=False)
8sampler = backend.sampler(default_shots=1024)
9estimator = backend.estimator(default_precision=0.1, abelian_grouping=True)
10
11samples = sampler.run([measured_circuit]).result()[0]
12counts = samples.data.meas.get_counts()
13estimate = estimator.run([(circuit, SparsePauliOp("ZZ"))]).result()[0]
14assert sum(counts.values()) == 1024
15assert set(counts) <= {"00", "11"}
16assert float(estimate.data.evs) == 1.0
17print("Bell counts:", counts)
18print("ZZ expectation:", float(estimate.data.evs))
Bell counts: {'00': 501, '11': 523}
ZZ expectation: 1.0
Sampler defaults to 1024 shots. Estimator requires positive precision and
defaults to 1/64 (4096 shots); it groups qubit-wise commuting measurements.
Both use Qiskit’s broadcasting, metadata, and asynchronous primitive jobs.
Calling result() waits for completion. PUBs with equal shot counts share a
backend batch; different shot counts or precisions follow Qiskit’s scheduling.
Primitive-job cancellation follows Qiskit’s future semantics: it does not abort
an already-running backend call.
Backend requirements¶
Feature |
Required QDMI result support |
|---|---|
Counts-only execution and native Estimator |
|
|
|
Native Sampler requests memory automatically. DDSIM supports both primitives;
counts-only devices must add SHOTS support to run Sampler. The backend never
reconstructs shots from counts; when memory is requested, it derives counts from
those same genuine shots.
1sampler = backend.sampler(default_shots=100)
2samples = sampler.run([qc]).result()[0]
3print(samples.data.meas.get_counts())
{'11': 49, '00': 51}
A device must advertise its supported operations and accept OpenQASM 2, OpenQASM
3, or a registered program format. Transpile circuits to
the backend target before submission. Estimator also needs the basis rotations
and measurements that Qiskit generates for the observables. Use the provider’s
specialized backend when its program dialect requires one, such as
amazon.braket.qdmi.qiskit.AmazonBraketBackend for Braket.
Results must contain one binary digit per classical bit, with clbits[0] on the
right, including unmeasured bits initialized to zero. Classical registers must
partition circuit.clbits in register order; loose, aliased, and reordered bits
are rejected. Serializers and providers must preserve this mapping. Shot order
is unchanged across registers, so joint samples and postselection remain valid.
The backend accepts nonnegative integer shots and max_retries options and a
boolean memory option. QDMI has no standard seed parameter, so the generic
backend rejects non-None seed_simulator. DDSIM’s
custom seed parameter is available through direct QDMI job
submission; other providers can define different custom parameters. Other
execution options are unsupported. The backend validates the whole batch before
submission, submits jobs in circuit order, and collects results in that order.
Missing memory, invalid bitstrings or shot totals, and failed or canceled jobs
raise instead of yielding partial or zero-filled samples.
Recovery lets you continue from accepted work
after a failure.
Error Handling¶
The module provides specific exceptions for different error conditions:
from mqt.core.plugins.qiskit import (
CircuitValidationError,
UnsupportedOperationError,
UnsupportedDeviceError,
JobSubmissionError,
TranslationError,
UnsupportedFormatError,
)
try:
job = backend.run(qc, shots=1024)
result = job.result()
except CircuitValidationError as e:
# Invalid circuit (unbound parameters, invalid shots, etc.)
print(f"Circuit validation failed: {e}")
except UnsupportedOperationError as e:
# Circuit contains operations not supported by device
print(f"Unsupported operation: {e}")
except UnsupportedDeviceError as e:
# Device cannot be represented in Qiskit's Target model
print(f"Unsupported device: {e}")
except JobSubmissionError as e:
# Failed to submit job to device
print(f"Job submission failed: {e}")
except TranslationError as e:
# Failed to convert circuit to supported program format
print(f"Translation error: {e}")
except UnsupportedFormatError as e:
# No supported program format available
print(f"Unsupported format: {e}")
Implementation Details¶
Circuit Serialization¶
When you run a circuit, the backend:
Validates the circuit (checks for unbound parameters, supported operations, valid options)
Serializes the circuit into one of the program formats supported by the target device, through the program serializer registered for that format
Submits the program to the QDMI device via
device.submit_job()Returns a
QDMIJob
The built-in OpenQASM serializers validate circuit width and ordered operation placements against native QDMI metadata after preprocessing. They reject an invalid circuit before any job in its batch is submitted. This check uses the native device sites because a backend extension may hide sites from its public Target or use preprocessing to address them. It does not route circuits.
Control-flow instructions require explicit support in the backend’s Target; their bodies are checked recursively using the enclosing circuit’s qubits. Advertising OpenQASM 3 alone does not enable control flow. Custom serializers retain responsibility for validating the native programs they produce.
Program Serializers¶
A program serializer turns one circuit into one program in one program format. MQT Core provides the serializers for OpenQASM 2 and OpenQASM 3. Every other format belongs to the package that owns the device, which registers its serializer through the same registry.
A format fixes the kind of payload it carries, so there are two signatures. A
text format takes a serializer that returns str:
def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> str: ...
A binary format takes one that returns bytes:
def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> bytes: ...
is_binary_program_format() states which kind a format
carries. The backend checks the returned type against the format and raises
TranslationError on a mismatch.
A serializer reads the device through
device and the supported
operations through
target.
A package advertises its serializers through the
mqt.core.qiskit.program_serializers entry point group. The entry point name is
the ProgramFormat member name:
[project.entry-points."mqt.core.qiskit.program_serializers"]
IQM_JSON = "iqm.qdmi.serializers:qiskit_to_iqm_json"
register_program_serializer() does
the same at run time:
from mqt.core.plugins.qiskit import register_program_serializer
from mqt.core.qdmi import ProgramFormat
register_program_serializer(ProgramFormat.IQM_JSON, qiskit_to_iqm_json)
Pass replace=True to take over a format that already has a serializer,
including OpenQASM 2 and OpenQASM 3.
A device usually accepts several formats. The backend walks them in the order of
PROGRAM_FORMAT_PREFERENCE and
uses the first one that has a serializer, so the order of the list decides and
not the order the device reports:
IQM_JSON, CUSTOM1 ... CUSTOM5,
QIR_ADAPTIVE_MODULE, QIR_ADAPTIVE_STRING,
QPY, QASM3,
QIR_BASE_MODULE, QIR_BASE_STRING,
QASM2
Device-native formats take precedence. Among standard formats, those with classical control precede restricted profiles; binary encoding wins ties within a QIR profile.
Device-specific execution options¶
Backend subclasses can add named execution options in _default_options and
implement _job_parameters(options) to validate and encode them as QDMI
custom1 through custom5 submission parameters. The hook receives the
subclass’s defaults merged with per-run overrides, excluding the base options
shots, memory, and max_retries. It runs before any circuit is submitted.
Custom values can be strings, scalars, or exact bytes, as accepted by
Device.submit_job. Unknown option names are rejected. The base hook rejects
declared options it cannot encode, so a subclass that adds options must override
it and handle each one. The generic backend defines no vendor-specific option
names or values.
Use backend.set_options(...) to configure defaults for direct runs, native
samplers, and native estimators. A direct backend.run(..., **options) call can
override those defaults without changing them. Native samplers also accept
backend.sampler(run_options={...}). Native estimators use the backend defaults
because Qiskit’s estimator does not expose a run_options field.
Device Introspection¶
The backend builds its Target by:
Querying the QDMI device for available operations
Mapping each operation to the corresponding Qiskit gate
Preserving each operation’s ordered site tuples, including gates on three or more qubits
Including operation properties (duration, fidelity) if available
Instruction durations use seconds: the backend multiplies raw QDMI durations by
the device’s duration scale factor and converts the advertised time unit. An
absent scale factor defaults to one. A reported duration with a missing or
unsupported unit, or an invalid scale factor, raises
UnsupportedOperationError.
Operations without duration metadata remain uncalibrated.
API Reference¶
For complete API documentation, see:
QDMIProvider— Device provider interfaceQDMIBackend— BackendV2 implementationQDMIJob— Job wrapper and result handlingexceptions— Exception types