PennyLane interface for gate-based QDMI devices¶
The mqt.core.plugins.pennylane module implements PennyLane’s device
interface for gate-based quantum devices exposed through QDMI. PennyLane
programs are preprocessed into executable tapes, converted to a program format
advertised by the selected QDMI device, submitted through the QDMI bindings, and
reconstructed from finite-shot QDMI results.
Any registered gate-based QDMI device can use this integration if it advertises OpenQASM 3 or OpenQASM 2, accepts finite-shot jobs, and returns computational-basis samples. Specialized neutral-atom interfaces, pulse-level control, and analytic execution are out of scope for now. The examples below use the local DD-based simulator device included with MQT Core and require no credentials or remote resources.
Install MQT Core with the optional PennyLane dependency into the active environment:
uv pip install "mqt-core[pennylane]"
Quickstart¶
A Bell-state circuit illustrates device discovery by stable ID, circuit conversion, execution, and finite-shot result reconstruction.
1import pennylane as qp
2
3bell_device = qp.device("mqt.ddsim.default", wires=2, job_parameters={"custom1": 7})
4
5
6@qp.qnode(bell_device, shots=1000)
7def bell_state():
8 qp.Hadamard(0)
9 qp.CNOT(wires=[0, 1])
10 return qp.counts(wires=[0, 1])
11
12
13bell_counts = bell_state()
14assert sum(bell_counts.values()) == 1000
15assert set(bell_counts) <= {"00", "11"}
16print({str(key): int(value) for key, value in sorted(bell_counts.items())})
/home/docs/checkouts/readthedocs.org/user_builds/mqt-core/checkouts/latest/.nox/docs/lib/python3.14/site-packages/autograd/wrap_util.py:38: SyntaxWarning: 'return' in a 'finally' block
return f
{'00': 489, '11': 511}
Only the computational-basis states \(00\) and \(11\) have nonzero probability, up to finite-shot fluctuations in their relative frequencies.
Program conversion¶
PennyLane first preprocesses every quantum tape. The preprocessing pipeline validates the execution request, decomposes higher-level operations, maps measurements to computational-basis sampling, and reconstructs the requested finite-shot results from the samples returned through QDMI.
MQT Core selects the program format in the following order:
OpenQASM 3 if the QDMI device advertises it.
OpenQASM 2 only if OpenQASM 3 is unavailable.
A format error before job creation if neither format is available.
The OpenQASM 3 converter selects operation spellings advertised by the QDMI
device and validates the program against its topology. If conversion fails, MQT
Core reports the OpenQASM 3 error rather than retrying with OpenQASM 2. A device
that advertises only OpenQASM 2 uses PennyLane’s qp.to_openqasm serializer
after device preprocessing.
The converter reuses successful capability checks for each gate and wire location within its device session. Every circuit still validates its own parameters and wire arguments. Open a new device session to use changed capabilities or topology.
QDMI waits and sample/count retrieval release the Python GIL, allowing unrelated Python threads to run while a provider waits or downloads results.
End-to-end use case: finite-shot MaxCut QAOA¶
Consider MaxCut on the fixed graph with \(E=\{(0,1),(0,2),(1,2),(2,3)\}\).
1from collections import Counter
2import time
3
4import matplotlib.pyplot as plt
5import networkx as nx
6import numpy as np
PennyLane constructs the cost and mixer Hamiltonians. The ansatz applies Hadamard gates followed by one QAOA cost layer and one mixer layer, parameterized by \(\gamma\) and \(\beta\).
1cost_hamiltonian, mixer_hamiltonian = qp.qaoa.maxcut(graph)
2
3
4def ansatz(parameters):
5 for wire in graph.nodes:
6 qp.Hadamard(wire)
7 qp.qaoa.cost_layer(parameters[0], cost_hamiltonian)
8 qp.qaoa.mixer_layer(parameters[1], mixer_hamiltonian)
9
10
11qaoa_device = qp.device("mqt.ddsim.default", wires=4, job_parameters={"custom1": 7})
12
13
14@qp.qnode(qaoa_device, shots=1000, diff_method="parameter-shift")
15def cost(parameters):
16 ansatz(parameters)
17 return qp.expval(cost_hamiltonian)
18
19
20@qp.qnode(qaoa_device, shots=1000)
21def sample(parameters):
22 ansatz(parameters)
23 return qp.sample(wires=range(4))
The calculation below evaluates the initial parameter-shift gradient and performs four gradient-descent updates. Each objective value is an independent finite-shot estimate; the resulting sequence is therefore not expected to be monotonic.
1parameters = qp.numpy.array([0.5, 0.5], requires_grad=True)
2optimizer = qp.GradientDescentOptimizer(stepsize=0.15)
3jobs_before = qaoa_device.submitted_jobs
4started = time.monotonic()
5
6objective_values = [float(cost(parameters))]
7initial_gradient = np.asarray(qp.grad(cost)(parameters), dtype=float)
8
9for _ in range(4):
10 parameters = optimizer.step(cost, parameters)
11 objective_values.append(float(cost(parameters)))
12
13samples = np.asarray(sample(parameters), dtype=np.int8)
14submitted_jobs = qaoa_device.submitted_jobs - jobs_before
15elapsed = time.monotonic() - started
16
17print(f"Initial gradient: {initial_gradient}")
18print(f"Final parameters: {np.asarray(parameters)}")
19print(f"QDMI jobs submitted: {submitted_jobs}")
20print(f"Elapsed time: {elapsed:.3f} s")
Initial gradient: [ 1.6305 -0.866 ]
Final parameters: [0.146225 0.97385 ]
QDMI jobs submitted: 91
Elapsed time: 2.223 s
Parameter-shift expands one gradient evaluation into several shifted tapes. Each executable tape is submitted as a distinct QDMI job. The device submits all jobs in one PennyLane batch before it waits for their ordered results, which lets asynchronous QDMI implementations execute them concurrently.
The sampled bit strings determine candidate bipartitions. The cut value is the number of graph edges whose endpoints have different bit values.
1def bitstring(sample_row):
2 return "".join(str(int(bit)) for bit in sample_row)
3
4
5def cut_value(candidate):
6 return sum(
7 candidate[first] != candidate[second] for first, second in graph.edges
8 )
9
10
11sample_counts = Counter(bitstring(row) for row in samples)
12best_bitstring = max(
13 sample_counts,
14 key=lambda candidate: (cut_value(candidate), sample_counts[candidate], candidate),
15)
16best_cut = cut_value(best_bitstring)
17
18print(f"Best sampled partition: {best_bitstring}")
19print(f"Cut edges: {best_cut} of {graph.number_of_edges()}")
Best sampled partition: 0110
Cut edges: 3 of 4
The following panels show the noisy objective estimates, the empirical bit-string distribution, and the highest-cut partition observed in the final sample. Orange edges cross that partition.
Direct generic construction¶
Stable entry points such as mqt.ddsim.default provide the simplest
construction. A device integration package can register a
QDMIDevice subclass under the
stable ID of another QDMI device. Applications may also construct the generic
class directly:
import pennylane as qp
from mqt.core.plugins.pennylane import QDMIDevice
device_id = "stable ID returned by the QDMI device registration"
device = QDMIDevice(
device_id=device_id,
wires=["a", "b", "c", "d"],
session_parameters={
"base_url": "device endpoint or selector",
"token": "...",
},
job_parameters={
"custom1": "device-specific job value",
},
)
An integration can return an already-open device. Pass that handle directly to the generic class. Do not repeat session parameters because the session already exists. For example, a Slurm job can reuse the device selected by its license:
from mqt.core.plugins.pennylane import QDMIDevice
from mqt.core.qdmi import slurm
device = QDMIDevice(
device=slurm.open_device_from_license(),
)
Arbitrary PennyLane wire labels map deterministically to contiguous QASM
indices. The converter validates fixed-arity loci and finite parameters for both
OpenQASM formats against the QDMI capabilities but does not route circuits. A
topology-incompatible program therefore fails before submission. Shot vectors,
batches, and parameter-shift tapes are submitted in order before their results
are collected in the same order. The PennyLane call remains synchronous, and
every execution requires finite shots. Set shots on the QNode or use
qp.set_shots to override them. Devices do not accept a shots argument or
provide a default shot count. Omitting finite shots fails before submission.
Use with qp.Tracker(device) as tracker: to record submitted jobs
(executions), requested shots, batches, and the number of tapes in each batch
(batch_len). Shot-vector copies count as separate jobs. Tracking records
accepted submissions, including jobs whose execution subsequently fails.
Mid-circuit measurements use PennyLane’s deferred-measurement transform. Reset
and feedback may require unused device wires; custom wire labels are supported.
The plugin rejects programs requiring more wires than are available and rejects
postselection. Explicit one-shot and tree-traversal requests are
unsupported; the plugin does not infer native dynamic-circuit support from gate
names.
Supported gate-level scope¶
The OpenQASM 3 path covers identity and Pauli gates; H, S, T, SX and supported
adjoints; RX, RY, RZ, and phase shift; controlled Pauli and phase gates;
Toffoli, SWAP, and CSWAP; ISWAP, PSWAP, and ECR; and Ising XX, XY, YY, and ZZ
rotations. PennyLane decomposes higher-level operations when their
decompositions reach operations advertised by the QDMI device. Enabling
qp.decomposition.enable_graph() also lets PennyLane choose registered graph
decompositions targeting those operations. The target set reflects the selected
OpenQASM serializer and advertised operation names; it does not imply native
hardware gates or provide routing.
The interface does not implement pulse programming, device-specific non-gate properties, routing, analytic execution, or QDMI batch jobs.