Run programs through QDMI

The compiler decides how a program fits a device. QDMI provides the next part of the workflow: discover that device, submit a job, wait for completion, and read its results. How do individual shots relate to a histogram? When can a simulator also return a statevector? Can you reuse a compiled program?

This notebook answers those questions with local DDSIM. It runs independently with the tutorial setup, without credentials or an external device. The QDMI guide explains the driver, client interfaces, device implementations, and SDK integrations.

Download this notebook.

Hide code cell source

1from collections import Counter
2from math import cos, pi, sin
3
4import numpy as np
5from qiskit.visualization import plot_distribution
6
7from mqt.core.mlir import compile_program, submit_program
8from mqt.core.qdmi import ProgramFormat
9from mqt.core.qdmi.driver import open_device, registered_device_ids

Discover a device and inspect its capabilities

Device IDs identify configured device definitions. Listing them does not submit a program. The bundled simulator has the stable ID mqt.ddsim.default:

1print("Registered devices:", registered_device_ids())
2device = open_device("mqt.ddsim.default")
3print("Device:", device.name())
4print("Status:", device.status().name)
5formats = device.supported_program_formats()
6print("Accepted formats:", [program_format.name for program_format in formats])
Registered devices: ['mqt.ddsim.default']
Device: MQT Core DDSIM QDMI Device
Status: IDLE
Accepted formats: ['QASM2', 'QASM3', 'QIR_BASE_STRING', 'QIR_BASE_MODULE', 'QIR_ADAPTIVE_STRING', 'QIR_ADAPTIVE_MODULE']

The accepted formats tell the client how to encode a program. Device queries also expose available operations, sites, and calibration properties. The target compiler uses that information; callers do not need to reproduce its gate mapping logic.

Opening another registered device may initialize access to a provider. Keep DDSIM selected here; use the configuration guide when connecting an external library or service.

Compile a program with a predictable distribution

Choose an angle \(\theta\). Applying \(R_y(\theta)\) to qubit 0 and a controlled-X to qubit 1 produces \(\cos(\theta/2)|00\rangle + \sin(\theta/2)|11\rangle\). For \(\theta=\pi/3\), predict the probabilities of 00 and 11.

 1theta = pi / 3
 2shots = 2048
 3seed = 17
 4source = f"""OPENQASM 3.0;
 5include "stdgates.inc";
 6qubit[2] q;
 7ry({theta}) q[0];
 8cx q[0], q[1];
 9bit[2] result = measure q;
10"""
11
12compiled = compile_program(source, target=device, program_format=ProgramFormat.QASM3)
13print("Selected format:", compiled.program_format.name)
14print(compiled.payload)
Selected format: QASM3
OPENQASM 3.1;
include "stdgates.inc";

output bit[2] result;

ry(1.0471975511965976) $0;
ctrl @ x $0, $1;
result[0] = measure $0;
result[1] = measure $1;

The returned CompiledProgram owns the payload and the target contract used to produce it. We selected OpenQASM 3 explicitly so this example can also inspect DDSIM’s state before terminal measurements. Omitting program_format lets the compiler choose from the device’s supported formats.

Submit, wait, and retrieve counts

Compilation does not submit a job. Name the destination explicitly when calling submit_program:

1job = submit_program(compiled, target=device, num_shots=shots, custom1=seed)
2assert job.wait()
3print("Final job status:", job.check().name)
4counts = job.get_counts()
5assert sum(counts.values()) == shots
6assert set(counts) == {"00", "11"}
7print(counts)
Final job status: DONE
{'00': 1507, '11': 541}

job.wait() waits for completion and reports a failed execution. A finite timeout lets a client stop waiting without cancelling the job. DDSIM uses custom1 as a positive integer random seed; custom parameters are defined by each device, not by the generic QDMI interface.

Compare the sampled distribution with the analytic prediction:

1expected = {"00": cos(theta / 2) ** 2, "11": sin(theta / 2) ** 2}
2assert abs(counts["00"] / shots - expected["00"]) < 0.05
3plot_distribution([expected, counts], legend=["Analytic probabilities", "Sampled counts"])
../_images/72d7de5ea04f53917a66b7e4ee79c9df2febd17a118dbaa72ef69dc4c94e3d29.png
Explain the result

The probabilities are \(3/4\) for 00 and \(1/4\) for 11. Finite-shot counts fluctuate around those values. Increasing the shot count reduces sampling noise; it does not change the underlying state.

Recover individual shots

A histogram loses ordering. get_shots() returns the individual outcomes in sampling order, and counting them must reconstruct the same histogram:

1samples = job.get_shots()
2assert len(samples) == shots
3assert dict(Counter(samples)) == counts
4assert job.get_counts() == counts
5print("First 16 shots:", samples[:16])
First 16 shots: ['00', '00', '00', '00', '00', '00', '00', '00', '11', '11', '11', '00', '00', '00', '00', '00']

Reading results again does not rerun the program. QDMI bitstrings put the highest-index output bit first. The QIR tutorial uses asymmetric outputs to make that ordering visible.

Inspect the state behind terminal measurements

DDSIM can prepare this program’s state once and sample it repeatedly. It retains that uncollapsed state, so the same job can also provide a statevector and probabilities:

1state = job.get_dense_statevector()
2probabilities = job.get_dense_probabilities()
3expected_state = np.array([cos(theta / 2), 0, 0, sin(theta / 2)], dtype=complex)
4np.testing.assert_allclose(state, expected_state, rtol=0, atol=1e-12)
5np.testing.assert_allclose(probabilities, np.abs(expected_state) ** 2, rtol=0, atol=1e-12)
6print("Statevector:", state)
7print("Probabilities:", probabilities)
Statevector: [(0.8660254037844387+0j), 0j, 0j, (0.5+0j)]
Probabilities: [0.7500000000000001, 0.0, 0.0, 0.25]

These are simulator results, not measurements available from physical hardware. They describe the state before terminal measurement, not one randomly collapsed shot. Jobs with feedback, resets, or other ineligible execution paths do not provide this state. The DDSIM guide specifies which sampling and zero-shot extraction jobs support it.

Reuse the compiled program

The number of shots is a job parameter. Submit the same compiled artifact again with a smaller shot count; no recompilation is needed while the target contract still matches:

1small_job = submit_program(compiled, target=device, num_shots=128, custom1=seed)
2assert small_job.wait()
3assert sum(small_job.get_counts().values()) == 128
4assert job.get_counts() == counts
5plot_distribution([small_job.get_counts(), counts], legend=["128 shots", "2048 shots"])
../_images/807858732372f6b18b740a9a165ca44138284e87900b8ccab855ac1b01b3e341.png

Experiment: change theta to pi / 2, rerun from the source cell, and predict the new probabilities. Changing the circuit requires recompilation; changing only the shot count requires another submission.

Change the program format, keep the job API

DDSIM also accepts QIR. Recompile the same source as Base Profile bitcode and use the same submission and result methods:

1qir_compiled = compile_program(source, target=device, program_format=ProgramFormat.QIR_BASE_MODULE)
2assert isinstance(qir_compiled.payload, bytes)
3qir_job = submit_program(qir_compiled, target=device, num_shots=shots, custom1=seed)
4assert qir_job.wait()
5qir_counts = qir_job.get_counts()
6assert sum(qir_counts.values()) == shots
7assert abs(qir_counts.get("00", 0) / shots - expected["00"]) < 0.05
8plot_distribution([counts, qir_counts], legend=["OpenQASM 3", "QIR Base"])
../_images/ae313cc2d53249e942e07c19e0e3c4e56084853d0c574310d48b60aa685668ae.png

The payload changed from text to bytes, but the QDMI job workflow stayed the same. Different execution paths can use different random sequences; compare the semantics and distribution rather than requiring identical counts.

Use Qiskit or PennyLane to access QDMI through an SDK, and Slurm to connect jobs to an HPC allocation. A compiled artifact must still match the selected device’s capabilities; a stable ID does not make artifacts portable to an unrelated target.

Continue with Exchange structured programs with jeff to exchange a structured program before choosing its final execution format or device.