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': 500, '11': 524}
Provider and Device Discovery¶
Using the Provider¶
The QDMIProvider discovers
registered 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 apply explicit overrides to this fresh device session.
Their names and value types are described by
mqt.core.typing.QDMISessionParameters; persistent configuration
remains the default:
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¶
QDMIProvider does not define a
generic credential interface. It opens each registered device with its
persistent definition. Configure credentials through the selected QDMI device
implementation. For example, a provider can use a credential file, an
environment variable, or a platform credential-provider chain. See
QDMI device configuration for persistent session settings.
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,mcz,mcp,mcrx,mcry,mcrzNon-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
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': 95, '1': 5}, {'0': 73, '1': 27}, {'0': 50, '1': 50}]
Job Handling¶
Job Status¶
The QDMIJob wraps a QDMI job and
provides status tracking:
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}")
Multi-Circuit Execution¶
The backend supports both single-circuit and multi-circuit execution. You can submit multiple circuits in a single call:
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': 482, '11': 518}
Circuit 1 results: {'11': 1000}
Circuit 2 results: {'00': 258, '01': 261, '10': 227, '11': 254}
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: {'11': 537, '00': 487}
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': 51, '00': 49}
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 boolean memory options.
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. Remote IDs are queried only when needed. Submission or
collection failure triggers best-effort cancellation of submitted jobs;
cancellation errors do not replace the original error. Missing memory, invalid
bitstrings or shot totals, and failed or canceled jobs raise instead of yielding
partial or zero-filled samples. Successful repeated reads reuse the result.
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
A device-native format comes first, because a package that registers a
serializer for its own device’s format wants that format used. The standardized
formats follow in order of what a circuit may contain: the QIR adaptive profile
allows classical control, QPY carries a Qiskit circuit without loss, and
OpenQASM 3 expresses control flow, while the QIR base profile forbids classical
feedback and OpenQASM 2 has no control flow at all. Encoding only breaks a tie
within one profile, because it decides how the program travels rather than what
it may say. CALIBRATION and BATCH_JOB are absent because a serialized
circuit is not what they carry.
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