QIR in the MQT

The Quantum Intermediate Representation (QIR) expresses quantum programs in LLVM IR. The MQT Compiler Collection compiles OpenQASM, Qiskit circuits, and its own program objects to QIR 2.1. The bundled DDSIM device executes the result through QDMI, with the same job and result API used for OpenQASM.

This notebook follows that complete path: compile a program, inspect its profile and capability flags, choose LLVM text or bitcode, and retrieve counts or QIR output records. See [2] for background on QIR support in MQT.

Download this notebook and the shared requirements.txt. Follow the notebook setup to run it locally. All examples use the bundled simulator and need no hardware account.

For experiments that compare static circuits with measurement feedback, continue with the QIR tutorial.

Compile a Base Profile program

Use the Base Profile for a circuit whose measurements do not control subsequent quantum operations. This Bell circuit produces the outcomes 00 and 11.

 1from mqt.core.mlir import OutputFormat, QIRProfile, compile_program
 2
 3bell_qasm = """OPENQASM 3.0;
 4include "stdgates.inc";
 5qubit[2] q;
 6h q[0];
 7cx q[0], q[1];
 8bit[2] result = measure q;
 9"""
10
11base = compile_program(bell_qasm, output=OutputFormat.QIR_BASE)
12assert base.profile is QIRProfile.BASE
13print(base.llvm_ir)
; ModuleID = 'LLVMDialectModule'
source_filename = "LLVMDialectModule"

@qir.result_label_result_1 = internal constant [9 x i8] c"result_1\00"
@qir.result_label_result_0 = internal constant [9 x i8] c"result_0\00"
@qir.result_label_result = internal constant [7 x i8] c"result\00"

define i64 @main() #0 {
  call void @__quantum__rt__initialize(ptr null)
  br label %1

1:                                                ; preds = %0
  call void @__quantum__qis__h__body(ptr null)
  call void @__quantum__qis__cx__body(ptr null, ptr inttoptr (i64 1 to ptr))
  br label %2

2:                                                ; preds = %1
  call void @__quantum__qis__mz__body(ptr null, ptr null)
  call void @__quantum__qis__mz__body(ptr inttoptr (i64 1 to ptr), ptr inttoptr (i64 1 to ptr))
  br label %3

3:                                                ; preds = %2
  call void @__quantum__rt__array_record_output(i64 2, ptr @qir.result_label_result)
  call void @__quantum__rt__result_record_output(ptr null, ptr @qir.result_label_result_0)
  call void @__quantum__rt__result_record_output(ptr inttoptr (i64 1 to ptr), ptr @qir.result_label_result_1)
  ret i64 0
}

declare void @__quantum__rt__initialize(ptr)

declare void @__quantum__qis__h__body(ptr)

declare void @__quantum__qis__cx__body(ptr, ptr)

declare void @__quantum__qis__mz__body(ptr, ptr) #1

declare void @__quantum__rt__result_record_output(ptr, ptr)

declare void @__quantum__rt__array_record_output(i64, ptr)

attributes #0 = { "entry_point" "output_labeling_schema"="labeled" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="2" }
attributes #1 = { "irreversible" }

!llvm.module.flags = !{!0, !1, !2, !3, !4}

!0 = !{i32 1, !"qir_major_version", i32 2}
!1 = !{i32 7, !"qir_minor_version", i32 1}
!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
!3 = !{i32 1, !"dynamic_result_management", i1 false}
!4 = !{i32 2, !"Debug Info Version", i32 3}

The returned QIRProgram owns the compiled program. Its llvm_ir property is an LLVM assembly string. The entry-point attributes identify base_profile, the output schema, and the required static qubit and result capacities. The module flags identify QIR 2.1 and whether dynamic resource management is used.

For a custom pipeline, a QCProgram also provides to_qir(QIRProfile.BASE) and to_qir(QIRProfile.ADAPTIVE). These conversions consume that program unless copy=True is set. The compile_program function copies program objects by default and runs the coordinated optimization pipeline.

Execute LLVM text or bitcode

The serialization and QDMI format must agree:

Profile

LLVM text (str)

LLVM bitcode (bytes)

Base

ProgramFormat.QIR_BASE_STRING

ProgramFormat.QIR_BASE_MODULE

Adaptive

ProgramFormat.QIR_ADAPTIVE_STRING

ProgramFormat.QIR_ADAPTIVE_MODULE

Open DDSIM by its stable device ID and submit the Base program as text:

1from mqt.core.qdmi import ProgramFormat
2from mqt.core.qdmi import open_device
3
4device = open_device("mqt.ddsim.default")
5job = device.submit_job(base.llvm_ir, ProgramFormat.QIR_BASE_STRING, num_shots=256, custom1=7)
6assert job.wait()
7counts = job.get_counts()
8assert set(counts) <= {"00", "11"} and sum(counts.values()) == 256
9counts
{'00': 122, '11': 134}

custom1 is DDSIM’s positive integer sampling seed. Use to_bitcode() to serialize the same program to LLVM bitcode, then submit those bytes without an intermediate file:

1bitcode = base.to_bitcode()
2assert isinstance(bitcode, bytes)
3binary_job = device.submit_job(bitcode, ProgramFormat.QIR_BASE_MODULE, num_shots=256, custom1=7)
4assert binary_job.wait()
5assert binary_job.get_program(bytes) == bitcode
6assert binary_job.get_counts() == counts
7print(f"Bitcode: {len(bitcode)} bytes")
8print(binary_job.get_counts())
Bitcode: 3060 bytes
{'00': 122, '11': 134}

To save a program, use base.write_bitcode("bell.bc") for bitcode or Path("bell.ll").write_text(base.llvm_ir) for LLVM text after importing Path from pathlib. The command-line equivalents are:

mqt-cc bell.qasm --emit=qir-base -o bell.ll
mqt-cc bell.qasm --emit=qir-adaptive -o bell.bc

Adaptive Profile and capability flags

The Adaptive Profile permits measurement-dependent control flow. This program measures a superposition, then flips and measures the qubit again if the result was 1. It always returns 0; the loop exits after at most one iteration.

 1feedback_qasm = """OPENQASM 3.0;
 2include "stdgates.inc";
 3qubit q;
 4h q;
 5bit result = measure q;
 6while (result) {
 7    x q;
 8    result = measure q;
 9}
10"""
11
12adaptive = compile_program(feedback_qasm, output=OutputFormat.QIR_ADAPTIVE)
13assert adaptive.profile is QIRProfile.ADAPTIVE

The compiler derives capability flags from the lowered program. There is no need to write LLVM metadata yourself. Inspect the entry-point attributes and flags:

Hide code cell source

1for line in adaptive.llvm_ir.splitlines():
2    if line.startswith(("attributes #0", "!")):
3        print(line)
4assert '"qir_profiles"="adaptive_profile"' in adaptive.llvm_ir
5assert '"backwards_branching"' in adaptive.llvm_ir
attributes #0 = { "entry_point" "output_labeling_schema"="labeled" "qir_profiles"="adaptive_profile" "required_num_qubits"="0" "required_num_results"="0" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6}
!0 = !{i32 1, !"qir_major_version", i32 2}
!1 = !{i32 7, !"qir_minor_version", i32 1}
!2 = !{i32 1, !"dynamic_qubit_management", i1 true}
!3 = !{i32 1, !"dynamic_result_management", i1 true}
!4 = !{i32 1, !"backwards_branching", i2 -2}
!5 = !{i32 1, !"arrays", i1 true}
!6 = !{i32 2, !"Debug Info Version", i32 3}

For this program, backwards_branching records the conditional loop, arrays records the result array, and dynamic_qubit_management and dynamic_result_management describe resource allocation. LLVM prints the conditional-loop bit pattern as i2 -2 (binary 10). Other programs can also carry int_computations, float_computations, ir_functions, multiple_target_branching, and multiple_return_points flags when their lowered instructions require those capabilities.

A profile name alone does not describe every device’s capabilities. When compiling for a device, use the target-compilation API to select its supported operations, program format, and control-flow capabilities. DDSIM supports this Adaptive program in both encodings:

1for payload, program_format in (
2    (adaptive.llvm_ir, ProgramFormat.QIR_ADAPTIVE_STRING),
3    (adaptive.to_bitcode(), ProgramFormat.QIR_ADAPTIVE_MODULE),
4):
5    adaptive_job = device.submit_job(payload, program_format, num_shots=32, custom1=7)
6    assert adaptive_job.wait()
7    assert adaptive_job.get_counts() == {"0": 32}
8    print(program_format.name, adaptive_job.get_counts())
QIR_ADAPTIVE_STRING {'0': 32}
QIR_ADAPTIVE_MODULE {'0': 32}

From a Qiskit circuit to QIR

Pass a Qiskit QuantumCircuit directly to compile_program. After constructing a circuit, conversion is one line; obtaining bitcode takes one more:

Hide code cell source

1from qiskit import QuantumCircuit
2
3circuit = QuantumCircuit(2, 2)
4circuit.h(0)
5circuit.cx(0, 1)
6circuit.measure([0, 1], [0, 1])
7circuit.draw("mpl")
../_images/76bd8f4eae2ceb9c3209869a39bda59abb319b0724cc5224817bc6e094fedd03.png
1qir = compile_program(circuit, output=OutputFormat.QIR_BASE)
2bitcode = qir.to_bitcode()

Select OutputFormat.QIR_ADAPTIVE for supported Qiskit control flow. The Qiskit interface guide describes the accepted operations and circuits. Compilation preserves the input circuit.

When execution is the goal, let the compiler select a device-compatible format:

1from mqt.core.mlir import submit_program
2
3compiled = compile_program(circuit, target=device)
4qiskit_job = submit_program(compiled, target=device, num_shots=256, custom1=7)
5assert qiskit_job.wait()
6assert set(qiskit_job.get_counts()) <= {"00", "11"}
7print(compiled.program_format.name)
8print(qiskit_job.get_counts())
QIR_ADAPTIVE_MODULE
{'00': 122, '11': 134}

Retrieve the QIR output stream through QDMI

Counts summarize recorded measurement results and Boolean values. Adaptive compilation records returned CBit registers containing computed values as Booleans, preserving bit order and overwritten values. Registers containing only measurement results retain result-array recording. QIR’s textual output stream also preserves output labels, array and tuple records, other recorded scalar values, and shot framing. Enable capture with DDSIM’s boolean custom2 parameter, then request its string result from CustomProperty.CUSTOM1:

 1from mqt.core.qdmi import CustomProperty
 2
 3recorded_job = device.submit_job(
 4    adaptive.to_bitcode(), ProgramFormat.QIR_ADAPTIVE_MODULE, num_shots=2, custom1=7, custom2=True
 5)
 6assert recorded_job.wait()
 7output = recorded_job.get_custom_result(CustomProperty.CUSTOM1, str)
 8assert isinstance(output, str)
 9assert output.count("START\n") == 2 and output.count("END\t0\n") == 2
10print(output, end="")
11print("Counts:", recorded_job.get_counts())
12assert recorded_job.get_counts() == {"0": 2}
HEADER	schema_id	labeled
HEADER	schema_version	2.1
START
METADATA	entry_point
METADATA	output_labeling_schema	labeled
METADATA	qir_profiles	adaptive_profile
METADATA	required_num_qubits	0
METADATA	required_num_results	0
OUTPUT	RESULT_ARRAY	0	result
END	0
START
METADATA	entry_point
METADATA	output_labeling_schema	labeled
METADATA	qir_profiles	adaptive_profile
METADATA	required_num_qubits	0
METADATA	required_num_results	0
OUTPUT	RESULT_ARRAY	0	result
END	0
Counts: {'0': 2}

The parameter and result slots are separate: job parameter CUSTOM1 is the seed; job result CUSTOM1 contains the captured text. The same capture API works for Base and Adaptive profiles, with text or bitcode input.

Capture is off by default. Enabling it executes the program for each shot and retains the complete stream in memory, so start with a small shot count. The stream uses QIR record order; QDMI shots and counts place the highest-index bit first. Capture jobs still expose those normal results, but do not retain an uncollapsed statevector. OpenQASM jobs and zero-shot state-extraction jobs reject capture. See DDSIM for the C API parameter types.

Execution contracts

The following details matter when exchanging QIR with another compiler or building on the C++ runtime. For ordinary compilation and execution, the Python examples above handle serialization, runtime setup, and result retrieval.

Runtime and QIS compatibility

QIR entry points take no arguments and return an i64 exit code. Bind named program parameters before lowering to either supported profile. Runtime and QIS declarations are checked before JIT compilation; a mismatched or unsupported declaration is reported with its actual and accepted LLVM function types.

MQT Core implements the QIR 2.1 Base and Adaptive Profile runtime APIs. The JIT accepts one exact LLVM type for each runtime declaration, so unsupported or outdated overloads fail before execution.

MQT Core provides dedicated QIS functions for variants with one or two control qubits, using the c<gate> and cc<gate> names. Operations with three or more controls use generic __ctl and __ctladj specializations. The control qubits are passed in an Array; parameterized and multi-target gates pass their original arguments in a Tuple, following the QIR-Runner calling convention. MQT accepts these functions as implementation-specific extensions to the QIR 2.1 Base and Adaptive profiles, so the entry point keeps its base_profile or adaptive_profile attribute.

MQT’s two-angle phased-X rotation gate uses the prx QIS stem. The incompatible QIR-Runner Pauli-axis operation named r is not part of MQT’s QIS.

Payloads and result access

The QDMI Device accepts jobs in the following program formats: QASM2, QASM3, QIR Base/Adaptive Profile Module (LLVM bitcode), and QIR Base/Adaptive Profile String (LLVM assembly).

QDMI C++ applications pass a std::string to Device::submitJob for a text program, or a std::span<const std::byte> for a binary module. The same method accepts a span of text or binary programs for a multi-program job. Text includes the required terminator once; binary payloads preserve their exact bytes, including embedded nulls. Job::getProgramBytes() retrieves such a payload without interpreting its format or removing terminal null bytes; the existing Job::getProgram() remains the textual, null-terminated accessor. It rejects known binary and non-text formats based on their QDMI format identifier, even if their payload happens to end in a null byte.

The Python API follows the same distinction: pass str or bytes to Device.submit_job for one program, or a sequence of either type for multiple programs. Job.get_program(bytes) returns the exact payload. By default, Job.get_program() decodes a null-terminated text payload and rejects known binary or non-text formats. The num_shots argument is optional for device-defined formats that encode their repetition count in the program payload.

Every DDSIM QIR job owns its JIT session, runtime, simulator state, random-number generator, and output settings. QIR jobs can therefore execute concurrently without sharing measurements or output records. DDSIM records result bits directly and formats textual records only when capture is enabled.

For sampling programs that cannot use terminal sampling, the JIT applies LLVM O1 optimization with automatic loop unrolling disabled. This reduces repeated classical work at the cost of additional compilation time. State extraction and programs eligible for terminal sampling skip this optimization. Exported QIR uses profile-preserving cleanup, including promotion of local classical storage to SSA values; host optimization applies only to the JIT’s private module.

QIR runtime functions do not propagate C++ exceptions. Allocations report invalid resource requests and runtime limits through the QIR error-output pointer when one is supplied. Other failures, including host-memory exhaustion, stop the isolated DDSIM worker and fail its assigned program; concurrent and later jobs remain usable.

Sampling and state extraction

Sampling supports Base and Adaptive formats. With output capture disabled, for either profile with an acyclic, unconditional entry path, constant gate arguments, terminal Z measurements and scalar result records, DDSIM prepares the DD once and samples it for all shots. The runtime retains repeated or reordered result records in program order, including after SWAPs. The QDMI device reverses each shot for most-significant-bit first serialization before constructing its histogram. Programs with classical memory accesses, helper calls, conditional branches, resets, dynamic resources, Boolean output records, or generic controlled argument arrays use ordinary per-shot execution. These inputs remain supported by the runner; they are not eligible for this sampling optimization, including when all Boolean records are constant. A fixed seed reproduces a shot sequence for the same execution path; sequences need not match across different sampling algorithms or software versions.

When provided for static resources, required_num_qubits and required_num_results specify capacities, and out-of-range IDs are rejected. Extracted states include unused qubits within the declared capacity, initialized to zero. Programs without those attributes retain the runtime’s inference of static IDs or dynamic allocations. During sampling, dynamic qubit release resets and recycles the simulator wire; the qubit limit applies to peak simultaneous allocations rather than their cumulative number in a shot. Released handles remain invalid.

Statevector extraction supports Base and Adaptive formats. Base extraction stops before the first irreversible call and requires a terminal irreversible region; defined or indirect helpers remain unsupported for that path.

Adaptive extraction executes classical loops, branches, dynamically computed gate arguments, dynamic allocations and direct helper calls while deferring Z measurements. A measured wire cannot participate in later gates, controls or SWAPs; independent wires may still evolve. Resets and measurement-dependent computation are unsupported. Result reads must be unused or feed only direct boolean output records. Indirect calls and unknown external functions are rejected before execution. Calls cannot re-enter the entry point. Initialization, when present, must be the first instruction of the entry point. These restrictions also apply inside helpers.

During Adaptive extraction, each executed allocation adds a zero-initialized wire. Release calls mark lifetimes without resetting or recycling simulator wires, so the exported state retains all allocated wires in allocation order, including unused and released wires. The qubit limit therefore applies to all allocations in one extraction run. Each run resets the quantum runtime. Output records are suppressed, and unsupported operations produce a failed QDMI job. Both profiles preserve global phase and logical wire order, including SWAPs. LLVM target triples must match the host architecture and operating system because the JIT executes in process.