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, shots=1000)
4
5
6@qp.qnode(bell_device)
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()
14bell_counts
{np.str_('00'): np.int64(510), np.str_('11'): np.int64(490)}
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.
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
1graph = nx.Graph([(0, 1), (0, 2), (1, 2), (2, 3)])
2positions = {
3 0: (-1.0, 0.75),
4 1: (-1.0, -0.75),
5 2: (0.25, 0.0),
6 3: (1.35, 0.0),
7}
8
9figure, axis = plt.subplots(figsize=(5.5, 3.3))
10nx.draw_networkx(
11 graph,
12 pos=positions,
13 ax=axis,
14 node_color="#4c78a8",
15 edge_color="#5f6368",
16 font_color="white",
17 node_size=850,
18 width=2,
19)
20axis.set_title("Four-node MaxCut instance")
21axis.set_axis_off()
22figure.tight_layout()
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, shots=1000)
12
13
14@qp.qnode(qaoa_device, diff_method="parameter-shift")
15def cost(parameters):
16 ansatz(parameters)
17 return qp.expval(cost_hamiltonian)
18
19
20@qp.qnode(qaoa_device)
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.6015 -0.688 ]
Final parameters: [0.0662 0.8456]
QDMI jobs submitted: 91
Elapsed time: 2.384 s
Parameter-shift expands one gradient evaluation into several shifted tapes. Each executable tape is submitted as a distinct QDMI job. Jobs are submitted sequentially; parallel QDMI submission is not supported.
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: 1010
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.
1figure, axes = plt.subplots(1, 3, figsize=(14, 3.8))
2
3axes[0].plot(
4 range(len(objective_values)),
5 objective_values,
6 marker="o",
7 color="#4c78a8",
8)
9axes[0].set(
10 xlabel="Optimizer update",
11 ylabel=r"$\langle H_C \rangle$",
12 title="Noisy finite-shot objective estimates",
13 xticks=range(len(objective_values)),
14)
15axes[0].grid(alpha=0.25)
16
17ordered_bitstrings = sorted(sample_counts)
18axes[1].bar(
19 ordered_bitstrings,
20 [sample_counts[candidate] for candidate in ordered_bitstrings],
21 color="#4c78a8",
22)
23axes[1].set(
24 xlabel="Bit string",
25 ylabel="Observed count",
26 title="Final sampled distribution",
27)
28axes[1].tick_params(axis="x", rotation=60)
29
30node_colors = [
31 "#4c78a8" if best_bitstring[node] == "0" else "#e45756"
32 for node in graph.nodes
33]
34edge_colors = [
35 "#f28e2b"
36 if best_bitstring[first] != best_bitstring[second]
37 else "#9aa0a6"
38 for first, second in graph.edges
39]
40edge_widths = [
41 3.2 if best_bitstring[first] != best_bitstring[second] else 1.5
42 for first, second in graph.edges
43]
44nx.draw_networkx(
45 graph,
46 pos=positions,
47 ax=axes[2],
48 node_color=node_colors,
49 edge_color=edge_colors,
50 width=edge_widths,
51 font_color="white",
52 node_size=850,
53)
54axes[2].set_title(f"Best sampled cut: {best_cut} edges")
55axes[2].set_axis_off()
56
57figure.tight_layout()
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"],
shots=[(100, 2), 500],
session_parameters={
"base_url": "device endpoint or selector",
"token": "...",
},
job_parameters={
"custom1": "device-specific job value",
},
)
Arbitrary PennyLane wire labels map deterministically to contiguous QASM indices. The converter validates the one- and two-qubit loci advertised through QDMI but does not route circuits. A topology-incompatible program therefore fails before submission. Shot vectors, batches, and parameter-shift tapes are executed in order, and every execution requires finite shots.
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.
The interface does not implement pulse programming, device-specific non-gate properties, routing, analytic execution, or parallel job submission.