Using the MQT Compiler Collection from Python

The mqt.core.mlir module provides Python access to the MQT Compiler Collection. It accepts source strings, .qasm, .mlir, and .jeff files, MQT QuantumComputation objects, Qiskit QuantumCircuit objects, and typed compiler programs. The requested output format determines where compilation stops and which program type is returned.

Install MQT Core and import the compiler interface:

1from mqt.core.mlir import OutputFormat, QCProgram, QIRProfile, compile_program

To compile for a configured QDMI device, see target compilation.

Compile an OpenQASM program

The following OpenQASM program prepares a Bell state and records the outcome of measuring both qubits.

 1bell_qasm = """OPENQASM 3.0;
 2include "stdgates.inc";
 3
 4qubit[2] q;
 5bit[2] result;
 6
 7h q[0];
 8cx q[0], q[1];
 9result = measure q;
10"""
11
12compiled = compile_program(bell_qasm)
13print(compiled.ir)
module {
  func.func @main() -> memref<2xi1> attributes {passthrough = ["entry_point"]} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %alloc = memref.alloc() {mqt.qubit_register_name = "q"} : memref<2x!qc.qubit>
    %alloc_0 = memref.alloc() {mqt.classical_register_name = "result"} : memref<2xi1>
    %0 = memref.load %alloc[%c0] : memref<2x!qc.qubit>
    qc.h %0 : !qc.qubit
    %1 = memref.load %alloc[%c1] : memref<2x!qc.qubit>
    qc.ctrl(%0) targets (%arg0 = %1) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    %2 = qc.measure %0 : !qc.qubit -> i1
    memref.store %2, %alloc_0[%c0] : memref<2xi1>
    %3 = qc.measure %1 : !qc.qubit -> i1
    memref.store %3, %alloc_0[%c1] : memref<2xi1>
    memref.dealloc %alloc : memref<2x!qc.qubit>
    return %alloc_0 : memref<2xi1>
  }
}

By default, compile_program() runs the standard optimization pipeline and returns a QCProgram. Its ir property exposes the textual MLIR representation for inspection and debugging. Programs do not need to be written in MLIR to use the compiler.

Important

The compiler removes dead code. A circuit that only prepares a state has no observable effect and will be removed by optimizations. Programs intended for execution should measure the relevant qubits and return the measurement results.

In OpenQASM 3, assigning measurements to a classical register, as in the example above, makes those results return values of the imported program. When constructing MLIR directly, return the values produced by the measurement operations.

Select an output format

Select an output format to stop the pipeline at a particular representation:

Purpose

Output format

Result type

Inspect frontend translation

OutputFormat.QC_IMPORT

QCProgram

Inspect QCO immediately after conversion

OutputFormat.QCO

QCOProgram

Inspect QCO after optimization

OutputFormat.QCO_OPTIMIZED

QCOProgram

Obtain the optimized circuit

OutputFormat.QC (default)

QCProgram

Emit an optimized OpenQASM program

OutputFormat.OPENQASM3

OpenQASMProgram

Serialize a compiler program

OutputFormat.JEFF

JeffProgram

Generate QIR

OutputFormat.QIR_BASE or OutputFormat.QIR_ADAPTIVE

QIRProgram

For example, select optimized QCO to inspect the representation after the default QCO pass pipeline:

1optimized = compile_program(bell_qasm, output=OutputFormat.QCO_OPTIMIZED)
2print(optimized.ir)
module {
  func.func @main() -> memref<2xi1> attributes {passthrough = ["entry_point"]} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %0 = qtensor.alloc(%c2) {mqt.qubit_register_name = "q"} : tensor<2x!qco.qubit>
    %alloc = memref.alloc() {mqt.classical_register_name = "result"} : memref<2xi1>
    %out_tensor, %result = qtensor.extract %0[%c0] : tensor<2x!qco.qubit>
    %1 = qco.h %result : !qco.qubit -> !qco.qubit
    %out_tensor_0, %result_1 = qtensor.extract %out_tensor[%c1] : tensor<2x!qco.qubit>
    %controls_out, %targets_out = qco.ctrl(%1) targets (%arg0 = %result_1) {
      %4 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %4 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    %qubit_out, %result_2 = qco.measure %controls_out : !qco.qubit
    memref.store %result_2, %alloc[%c0] : memref<2xi1>
    %2 = qtensor.insert %qubit_out into %out_tensor_0[%c0] : tensor<2x!qco.qubit>
    %qubit_out_3, %result_4 = qco.measure %targets_out : !qco.qubit
    %3 = qtensor.insert %qubit_out_3 into %2[%c1] : tensor<2x!qco.qubit>
    memref.store %result_4, %alloc[%c1] : memref<2xi1>
    qtensor.dealloc %3 : tensor<2x!qco.qubit>
    return %alloc : memref<2xi1>
  }
}

Emit OpenQASM

Request OPENQASM3 to emit the program after the normal QCO optimization and conversion back to QC:

1openqasm = compile_program(bell_qasm, output=OutputFormat.OPENQASM3)
2print(openqasm.source)
OPENQASM 3.1;
include "stdgates.inc";

qubit[2] q;
output bit[2] result;

h q[0];
ctrl @ x q[0], q[1];
bit _mqt_b0 = measure q[0];
result[0] = _mqt_b0;
bit _mqt_b1 = measure q[1];
result[1] = _mqt_b1;

The returned OpenQASMProgram owns its source and can write it directly:

1from pathlib import Path
2from tempfile import TemporaryDirectory
3
4with TemporaryDirectory() as directory:
5    path = Path(directory) / "bell.qasm"
6    openqasm.write(path)
7    reparsed = QCProgram.from_qasm_file(path)
8
9assert reparsed.is_valid

Use to_openqasm3() to clean up and export the current QC program without QCO optimization. The resulting OpenQASMProgram can be passed directly to compile_program():

1recompiled = compile_program(openqasm, output=OutputFormat.QC_IMPORT)
2assert isinstance(recompiled, QCProgram)

The exporter targets practical structured programs with static qubit and bit indices. Dynamic indexing, dynamic ranges, surviving runtime assertions, checked-index machinery, and live poison values fail with an MLIR diagnostic. See OpenQASM input and output for the complete support table.

Use Qiskit circuits directly

Install the optional Qiskit integration with mqt-core[qiskit]. Qiskit 2.5.x circuits can be translated directly between QuantumCircuit and QCProgram:

 1from qiskit import QuantumCircuit
 2
 3qiskit_bell = QuantumCircuit(2, 2)
 4qiskit_bell.h(0)
 5qiskit_bell.cx(0, 1)
 6qiskit_bell.measure(range(2), range(2))
 7
 8direct = QCProgram.from_qiskit(qiskit_bell)
 9restored = direct.to_qiskit()
10compiled_qiskit = compile_program(qiskit_bell)
11
12assert direct.is_valid  # Export does not consume the QC program.
13assert restored.count_ops() == qiskit_bell.count_ops()
14assert compiled_qiskit.is_valid

This compiler route does not construct an intermediate QuantumComputation. The existing qiskit_to_mqt(), mqt_to_qiskit(), and mqt.core.load() interfaces remain independent and retain their existing version range and behavior.

Import and export have different contracts because Qiskit 2.5 can inspect more program structures than its C API can construct.

Circuit feature

Import

Export

Standard gates, constructible numeric modifiers, and global phase

Supported

Supported

Other finite numeric modifiers

Supported

Rejected

Measurement, reset, and barrier

Supported

Supported

Canonical named registers and leading loose bits

Supported

Supported

Custom instructions with finite, acyclic definitions

Recursively expanded

Not applicable

Nested if/else, for, while, and switch

Supported

Rejected

Classical-bit and register conditions

Supported

Rejected

Constant Boolean, Uint up to 64 bits, and Float expressions

Supported

Rejected

Standalone classical variables or variable expressions

Rejected

Rejected

Free symbolic parameters

Rejected

Rejected

Arbitrary unitaries

Rejected

Rejected

Register aliases or interleaved membership

Rejected

Rejected

Transpiler layout metadata

Accepted and ignored

Not emitted

Lexically bound for-loop induction parameters are supported. Numeric parameters passed to a custom instruction are bound before its definition is expanded. Definition expansion rejects missing definitions, cycles, operand arity mismatches, nesting beyond 64 levels, and more than 10 million expanded operations.

A circuit remains valid when circ.layout is present. The importer translates the circuit operations and deliberately does not preserve physical or virtual layout metadata.

Input validation finishes before an MLIR module is created. Output validation finishes before a Qiskit circuit is allocated. Unsupported programs therefore fail without modifying the source object or exposing a partial result.

The binding imports Qiskit only when circuit translation is requested. It accepts versions in the registered >=2.5.0,<2.6.0 range and verifies the native API version before reading a circuit.

Run passes explicitly

QCProgram, QCOProgram, JeffProgram, and QIRProgram own their MLIR modules. Conversions between these MLIR-backed program objects consume their source by default, avoiding an implicit copy of a potentially large module. Pass copy=True when the source must remain available. OpenQASMProgram instead owns immutable source text and remains reusable when passed to compile_program.

The following example keeps the imported QC program, applies transformations to QCO, and converts the result back to QC:

 1qc = QCProgram.from_qasm_str(bell_qasm)
 2qco = qc.to_qco(copy=True)
 3qco.cleanup()
 4qco.merge_single_qubit_rotation_gates()
 5qco.lift_hadamards()
 6final_qc = qco.to_qc()
 7
 8assert qc.is_valid
 9assert not qco.is_valid
10print(final_qc.ir)
module {
  func.func @main() -> memref<2xi1> attributes {passthrough = ["entry_point"]} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %alloc = memref.alloc() {mqt.qubit_register_name = "q"} : memref<2x!qc.qubit>
    %alloc_0 = memref.alloc() {mqt.classical_register_name = "result"} : memref<2xi1>
    %0 = memref.load %alloc[%c0] : memref<2x!qc.qubit>
    qc.h %0 : !qc.qubit
    %1 = memref.load %alloc[%c1] : memref<2x!qc.qubit>
    qc.ctrl(%0) targets (%arg0 = %1) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    %2 = qc.measure %0 : !qc.qubit -> i1
    memref.store %2, %alloc_0[%c0] : memref<2xi1>
    %3 = qc.measure %1 : !qc.qubit -> i1
    memref.store %3, %alloc_0[%c1] : memref<2xi1>
    memref.dealloc %alloc : memref<2x!qc.qubit>
    return %alloc_0 : memref<2xi1>
  }
}

Architecture-independent QCO transformations can also be composed with MLIR’s textual pass-pipeline syntax. The same pass names and options are accepted by mqt-cc:

1custom = compile_program(
2    bell_qasm,
3    output=OutputFormat.QCO_OPTIMIZED,
4    qco_pipeline="hadamard-lifting,merge-single-qubit-rotation-gates",
5)

The raw qubit-reuse pass and its composite preparation pipeline are both available through the compiler collection:

1raw_reuse = compile_program(bell_qasm, output=OutputFormat.QCO)
2raw_reuse.reuse_qubits()
3
4composite_reuse = compile_program(bell_qasm, output=OutputFormat.QCO)
5composite_reuse.run_qubit_reuse_pipeline()

The same flows can be composed with the default optimization pipeline in the compiler driver:

mqt-cc input.qasm --emit=qco-optimized \
  --pass-pipeline='builtin.module(reuse-qubits,mqt-qco-default)'
mqt-cc input.qasm --emit=qco-optimized \
  --pass-pipeline='builtin.module(mqt-qubit-reuse,mqt-qco-default)'

The mqt-qubit-reuse pipeline lifts measurements and replaces classical controls before applying the raw reuse-qubits pass.

The qco_pipeline argument replaces the default QCO optimization pipeline. It is applied when compilation proceeds beyond the raw OutputFormat.QCO checkpoint.

Serialize programs and generate QIR

jeff is a serializable representation that can be stored and compiled again in a later process.

 1from pathlib import Path
 2from tempfile import TemporaryDirectory
 3
 4with TemporaryDirectory() as directory:
 5    path = Path(directory) / "bell.jeff"
 6    jeff = compile_program(bell_qasm, output=OutputFormat.JEFF)
 7    jeff.write(path)
 8    restored = compile_program(path, output=OutputFormat.QC)
 9
10assert restored.is_valid

To generate QIR, select a target profile. QIRProgram provides the QIR MLIR through ir and the translated LLVM IR through llvm_ir.

1qir = compile_program(bell_qasm, output=OutputFormat.QIR_BASE)
2assert qir.profile is QIRProfile.BASE
3print(qir.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}

Use to_bitcode() to obtain LLVM bitcode as bytes, or write_bitcode() to write a .bc file directly. The QIR guide shows how to execute the generated bytes directly with QIR-Runner’s qirrunner Python package.

The mqt-cc compiler driver selects the QIR serialization from the output filename. Use .ll for textual LLVM IR and .bc for LLVM bitcode:

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

Writing QIR to standard output also produces textual LLVM IR. All other output filenames, including filenames without an extension, retain the bitcode output used by earlier versions.

The QC, QCO, and QTensor references describe the underlying operations. See Conversions for the lowering steps between dialects.