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.
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"])
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"])
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"])
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.