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:

  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.

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

Hide code cell source

 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()
Four-node MaxCut graph with edges 01, 02, 12, and 23.

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.

Hide code cell source

 1figure, axes = plt.subplots(3, 1, figsize=(7, 10))
 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()
QAOA objective estimates, final counts, and best sampled graph 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.