Compile for hardware constraints

A logical program names qubits and gates. Hardware offers physical sites, connections, and a native gate set. What changes when the program’s interactions do not fit those connections?

This tutorial runs independently with the tutorial setup. The targets below are small models for compilation. Execution uses the bundled DDSIM simulator and requires no hardware account or external device.

Download this notebook.

Hide code cell source

 1from IPython.display import Code, display
 2from matplotlib import pyplot as plt
 3from qiskit.visualization import plot_distribution
 4
 5from mqt.core.mlir import (
 6    CompilerTarget,
 7    OutputFormat,
 8    QCProgram,
 9    compile_program,
10    sample,
11    submit_program,
12)
13from mqt.core.qdmi.driver import open_device

Predict the logical result

The first two controlled-X gates prepare a three-qubit GHZ state. The final controlled-X flips the third qubit when the first is 1. Starting from \(|000\rangle\), the result is \((|000\rangle + |011\rangle)/\sqrt{2}\) in basis order \(|q_2q_1q_0\rangle\).

The three gates connect every pair of logical qubits. No assignment of three qubits to a three-site line can make all three pairs adjacent.

 1source = """OPENQASM 3.0;
 2include "stdgates.inc";
 3qubit[3] q;
 4bit[3] result;
 5h q[0];
 6cx q[0], q[1];
 7cx q[1], q[2];
 8cx q[0], q[2];
 9result = measure q;
10"""
11shots = 4096
12seed = 17
13
14logical = QCProgram.from_openqasm_str(source)
15logical.to_qiskit().draw("mpl")
../_images/c2b71b4a1e12182b72cd92cedfb3e5c866abc1a8fd7262cfeddfe8dca552fd9d.png

Check the expected logical output before introducing a target:

1logical_counts = sample(source, shots=shots, seed=seed)
2assert set(logical_counts) == {"000", "011"}
3assert sum(logical_counts.values()) == shots
4assert abs(logical_counts["000"] / shots - 0.5) < 0.05
5plot_distribution(logical_counts, title="Logical output distribution")
../_images/5e3ca5330e8c6d923af8d8e515f45bab278656703a5e8f67fe65b81883565d5b.png

Describe two targets

A CompilerTarget is an immutable description used during compilation. It specifies sites, connectivity, and supported operations. It does not open a connection or execute a program.

Both models have three sites and the same native gates: RZ and RY rotations plus controlled-X. Measurement is also supported. H is not native, so it must be synthesized from rotations.

 1native_operations = CompilerTarget.NativeOperations([
 2    CompilerTarget.OperationCapability("rz", arity=1, num_parameters=1),
 3    CompilerTarget.OperationCapability("ry", arity=1, num_parameters=1),
 4    CompilerTarget.OperationCapability("cx", arity=2, num_parameters=0),
 5    CompilerTarget.OperationCapability("measure", arity=1, num_parameters=0),
 6])
 7line_edges = [(0, 1), (1, 2)]
 8targets = {
 9    "All-to-all": CompilerTarget(
10        3,
11        connectivity=CompilerTarget.Connectivity.all_to_all(),
12        native_operations=native_operations,
13    ),
14    "Line": CompilerTarget(
15        3,
16        connectivity=CompilerTarget.Connectivity(line_edges),
17        native_operations=native_operations,
18    ),
19}

arity is the number of qubits an operation acts on; num_parameters is its number of gate parameters. An omitted placement list makes an operation available on every placement permitted by the target topology. Our CX gates have no additional direction restriction.

Hide code cell source

 1positions = [(0, 0), (1, 0), (2, 0)]
 2fig, axes = plt.subplots(1, 2, figsize=(9, 2.4))
 3for ax, (name, edges) in zip(
 4    axes, [("All-to-all", [(0, 1), (1, 2), (0, 2)]), ("Line", line_edges)], strict=True
 5):
 6    for left, right in edges:
 7        if (left, right) == (0, 2):
 8            ax.annotate(
 9                "",
10                xy=positions[right],
11                xytext=positions[left],
12                arrowprops={
13                    "arrowstyle": "-",
14                    "connectionstyle": "arc3,rad=-0.45",
15                    "color": "#0065bd",
16                },
17            )
18        else:
19            ax.plot([left, right], [0, 0], color="#0065bd", linewidth=2)
20    ax.scatter([0, 1, 2], [0, 0, 0], s=650, color="#0065bd", zorder=3)
21    for site, (x, y) in enumerate(positions):
22        ax.text(x, y, str(site), ha="center", va="center", color="white", fontsize=12)
23    ax.set(title=name, xlim=(-0.4, 2.4), ylim=(-0.3, 0.8))
24    ax.axis("off")
25fig.suptitle("Physical sites and available connections")
26fig.tight_layout()
../_images/916cb2ba532c942bec0a3c9b3d94586d34c95cf44f67270cadcac695dac29ff4.png

These models omit noise and calibration. Their purpose is to isolate the effect of connectivity while keeping the native gate set fixed.

Compile, then inspect the native circuits

Placement assigns logical qubits to physical sites. Routing moves quantum states when an interaction requires different neighbors. Native synthesis expresses operations using the target’s supported gates. Routing can therefore increase the number of native gates even when target-independent optimization has removed redundant work.

Predict which target needs more controlled-X gates, then compile:

 1mapped = {
 2    name: compile_program(source, target=target, output=OutputFormat.OPENQASM3)
 3    for name, target in targets.items()
 4}
 5circuits = {
 6    name: QCProgram.from_openqasm_str(program.source).to_qiskit()
 7    for name, program in mapped.items()
 8}
 9for name, circuit in circuits.items():
10    print(name)
11    display(circuit.draw("mpl", fold=12))
All-to-all
../_images/f84ff372b8442934e3e133dde2a152fd47b6ab9beec361b673ca6e4bb584033a.png
Line
../_images/d7d1e65b4846a26d839a67e4a7bc32db38db8edba30a835b8d22a0d7ca68e2ac.png

The figures use the emitted physical site order. A routing exchange may already be decomposed into native gates, so do not expect an explicit SWAP symbol. Measurements still write to the declared result register, even when their source physical qubits have moved.

Compare the gate counts of these straight-line outputs:

1print("Target       RZ   RY   CX")
2for name, circuit in circuits.items():
3    counts = circuit.count_ops()
4    print(
5        f"{name:11} {counts.get('rz', 0):3}  {counts.get('ry', 0):3}  {counts.get('cx', 0):3}"
6    )
7assert circuits["Line"].count_ops()["cx"] > circuits["All-to-all"].count_ops()["cx"]
Target       RZ   RY   CX
All-to-all    1    1    3
Line         14    7    6

This experiment illustrates routing cost, not an optimality guarantee. The compiler uses heuristics; exact layouts and decompositions can change between versions or machines. Mapping uses one initial-layout trial per available CPU by default. The target guide describes explicit pass options for reproducible mapping experiments.

Verify that the compiler used native gates and that every emitted CX on the line joins adjacent sites:

Hide code cell source

 1for name, circuit in circuits.items():
 2    assert [(register.name, len(register)) for register in circuit.cregs] == [("result", 3)]
 3    assert set(circuit.count_ops()) <= {"rz", "ry", "cx", "measure", "store"}
 4    for instruction in circuit.data:
 5        if instruction.operation.name == "cx":
 6            sites = [circuit.find_bit(qubit).index for qubit in instruction.qubits]
 7            assert targets[name].supports_operation("cx", 2, 0, sites)
 8            if name == "Line":
 9                assert tuple(sorted(sites)) in line_edges
10print("Native gates and CX connectivity checks passed.")
Native gates and CX connectivity checks passed.

The explicit topology check matters: an operation capability with no placement list does not itself encode the coupling graph. The target compiler checks both contracts.

Preserve logical output bits after routing

Expand the emitted OpenQASM and inspect its final measurement assignments:

1for name, program in mapped.items():
2    print(name)
3    display(Code(program.source, language="openqasm3"))

Hide code cell output

All-to-all
OPENQASM 3.1;
include "stdgates.inc";

output bit[3] result;

rz(3.1415926535897931) $0;
ry(1.5707963267948966) $0;
ctrl @ x $0, $1;
ctrl @ x $1, $2;
ctrl @ x $0, $2;
result[0] = measure $0;
result[1] = measure $1;
result[2] = measure $2;
Line
OPENQASM 3.1;
include "stdgates.inc";

output bit[3] result;

rz(3.1415926535897931) $0;
ry(1.5707963267948966) $0;
ctrl @ x $0, $1;
ctrl @ x $1, $2;
rz(1.5707963267948968) $1;
ry(1.5707963267948966) $1;
rz(-1.5707963267948968) $1;
rz(-4.7123889803846897) $0;
ry(1.5707963267948966) $0;
rz(1.5707963267948966) $0;
ctrl @ x $0, $1;
rz(-1.5707963267948966) $1;
ry(1.5707963267948961) $0;
rz(-3.1415926535897936) $0;
ctrl @ x $0, $1;
rz(3.1415926535897931) $1;
ry(1.570796326794897) $1;
rz(-1.5707963267948963) $1;
rz(-3.1415926535897931) $0;
ry(1.570796326794897) $0;
rz(-1.5707963267948966) $0;
ctrl @ x $0, $1;
rz(1.5707963267948972) $1;
ry(1.570796326794897) $1;
rz(-1.5707963267948966) $1;
rz(1.5707963267948974) $0;
ctrl @ x $1, $2;
result[1] = measure $0;
result[0] = measure $1;
result[2] = measure $2;

$0, $1, and $2 identify physical sites. The assignments to result retain the logical output order. In particular, the rightmost character in a count string is result[0], not necessarily the measurement of physical site 0.

The H gate has become rotations. Neither target lists gphase as native, so target synthesis may remove the entry point’s unobservable global phase while preserving relative phase effects. Unlike the target-independent cancellation experiment, comparing raw unitary matrices without accounting for global phase and physical permutations would not be an appropriate check here.

Sample the emitted programs through the local QCO interpreter:

 1mapped_counts = {
 2    name: sample(program, shots=shots, seed=seed) for name, program in mapped.items()
 3}
 4for counts in mapped_counts.values():
 5    assert set(counts) == {"000", "011"}
 6    assert sum(counts.values()) == shots
 7    assert abs(counts["000"] / shots - 0.5) < 0.05
 8plot_distribution(
 9    [logical_counts, *mapped_counts.values()], legend=["Logical", *mapped_counts]
10)
../_images/b2dd2bcfb41f4aff0d6afab3ca7ba4dc1f99d541033ef569262cd01125e06cf0.png

Both targets retain the expected logical measurement distribution. This is an observable check for this input, not a proof of equivalence on every input state. Even with the same seed, transformed programs need not produce identical finite-shot histograms.

Experiment: remove the routing constraint

Replace the line with explicit triangle connectivity. Unlike all_to_all(), this exercises the explicit-topology mapping path, but every site pair is now connected. Predict whether the extra routing work remains:

 1triangle = CompilerTarget(
 2    3,
 3    connectivity=CompilerTarget.Connectivity([*line_edges, (0, 2)]),
 4    native_operations=native_operations,
 5)
 6triangle_program = compile_program(
 7    source, target=triangle, output=OutputFormat.OPENQASM3
 8)
 9triangle_circuit = QCProgram.from_openqasm_str(triangle_program.source).to_qiskit()
10triangle_counts = sample(triangle_program, shots=shots, seed=seed)
11assert set(triangle_counts) == {"000", "011"}
12assert sum(triangle_counts.values()) == shots
13assert abs(triangle_counts["000"] / shots - 0.5) < 0.05
14assert triangle_circuit.count_ops()["cx"] < circuits["Line"].count_ops()["cx"]
15print(triangle_circuit.count_ops())
OrderedDict({'cx': 3, 'measure': 3, 'rz': 1, 'ry': 1})
Explain the result

The triangle allows all three logical interactions without routing exchanges. Native synthesis is still required because H is not native. Connectivity and the native gate set impose separate requirements.

Experiment: provide too few sites

A compiler cannot place three simultaneously live qubits on two sites in this program. The following cell deliberately tries it, catches the exception, and shows the expected diagnostic. This is the only intended failure in the tutorial; the later cells still run.

 1too_small = CompilerTarget(
 2    2,
 3    connectivity=CompilerTarget.Connectivity.all_to_all(),
 4    native_operations=native_operations,
 5)
 6try:
 7    compile_program(source, target=too_small, output=OutputFormat.OPENQASM3)
 8except RuntimeError as error:
 9    print(f"Expected compilation failure: {error}")
10else:
11    raise AssertionError("The three-qubit program must not fit on this two-site target")
Expected compilation failure: Compiler action failed; see diagnostics for details.
loc("<qc-program-builder>":1:1): error: requires 3 program qubits, but the target site count is 2
loc("<qc-program-builder>":1:1): error: failed to compile the QCO program for the target

The diagnostic reports the required program qubits and available target sites. It does not indicate a Python syntax error or a missing installation. Qubit reuse is a separate optimization with its own applicability conditions; merely declaring a smaller target does not make it possible.

Connect compilation and execution with QDMI

The Quantum Device Management Interface (QDMI) defines a common C interface for querying device capabilities, submitting jobs, checking their status, and retrieving results. MQT Core’s driver loads device implementations and exposes them through Python and C++.

QDMI can connect to a local simulator, a QPU, or a cloud service. The device reports which program formats it accepts; QDMI does not require every device to use the same format. compile_program uses those capabilities to prepare a payload, and submit_program creates a job for it. The QDMI overview explains the layers and links to Amazon Braket and IQM integration case studies.

Compile for an execution device

A target model describes constraints. A QDMI device also accepts jobs. Open the packaged DDSIM device and compile the logical source for its actual capabilities:

1device = open_device("mqt.ddsim.default")
2compiled = compile_program(source, target=device)
3print(f"Selected program format: {compiled.program_format.name}")
4print(f"Payload type: {type(compiled.payload).__name__}")
Selected program format: QIR_ADAPTIVE_MODULE
Payload type: bytes

Here DDSIM selects Adaptive QIR. The CompiledProgram owns the serialized payload, the compiler target snapshot, and the selected payload specification. This also allows structured programs such as the previous notebook’s feedback example to be compiled for DDSIM.

Submission is a separate operation that explicitly names the destination:

1job = submit_program(compiled, target=device, num_shots=shots, custom1=seed)
2job.wait()
3device_counts = job.get_counts()
4assert set(device_counts) == {"000", "011"}
5assert sum(device_counts.values()) == shots
6assert abs(device_counts["000"] / shots - 0.5) < 0.05
7plot_distribution(device_counts, title="DDSIM logical outputs")
../_images/678d50529539aced714d401d7bd49369cc52466df53eb005bf3dc1a62bc5e50e.png

job.wait() waits for completion and reports execution failure. custom1 selects DDSIM’s random seed; custom job parameters are device-specific.

A CompiledProgram is tied to the target and payload contract used during compilation. Submission checks that the destination matches that contract. Do not compile a hardware-model artifact and assume that substituting DDSIM as its destination will work. Here we simulated the models’ OpenQASM for comparison, then compiled the logical source for DDSIM before submitting it. A compiled DDSIM program can be submitted again without recompilation while its contract continues to match.

Continue to execution and exchange

Continue with Execute programs with QIR to inspect the executable payload, then Run programs through QDMI to explore the device and job API. The Exchange structured programs with jeff tutorial shows how to hand programs to another compiler before choosing an execution format.

For further reference: