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:

  1. OpenQASM 3 if the QDMI device advertises it.

  2. OpenQASM 2 only if OpenQASM 3 is unavailable.

  3. 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()
../_images/7e27efd150b826d8b7c03549f105bc87520f712fbfbb4c5432447556885693bf.svg

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()
../_images/e3a5bb655ab4801e836fc4a5f3560534bb80759b5c9549f8b3a37d95af760ed2.svg

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.