Follow a program through the compiler

Why does the compiler use more than one representation of a quantum program? How can you tell whether an optimization changed its meaning? This tutorial answers these questions by compiling small programs, inspecting the intermediate representation (IR), and checking their results.

You need basic Python and quantum computing knowledge, but no MLIR experience. Follow the tutorial setup, then run the cells from top to bottom. Each notebook runs independently.

Download this notebook.

Hide code cell source

 1import numpy as np
 2from IPython.display import Code, display
 3
 4from mqt.core.mlir import (
 5    OutputFormat,
 6    QCProgram,
 7    build_functionality,
 8    compile_program,
 9    simulate,
10)

Start with a program you can predict

A Hadamard followed by a controlled-X prepares the Bell state \((|00\rangle + |11\rangle)/\sqrt{2}\) from \(|00\rangle\). Our program deliberately has three Hadamards. Since \(H^2 = I\), it should prepare the same state.

We begin with two individual qubits. Registers and measurements will come in the next notebook, so they do not obscure the first transformation.

 1source = """OPENQASM 3.0;
 2include "stdgates.inc";
 3qubit a;
 4qubit b;
 5h a;
 6h a;
 7h a;
 8cx a, b;
 9"""
10
11qc = QCProgram.from_openqasm_str(source)
12qc.to_qiskit().draw("mpl")
../_images/f4c9645c307cba13434e418258e678346b826a40e08a38de5598b5afca438a22.png

The circuit is read from left to right. The controlled-X acts on b, controlled by a. Predict which gates the compiler can remove before running a pass.

Read the imported QC

The compiler first translates OpenQASM into MLIR. MLIR is a framework for representing and transforming programs. A dialect groups related operations and types: qc.h is the Hadamard operation in MQT’s QC dialect, while func.func defines a function in MLIR’s func dialect.

1display(Code(qc.ir, language="mlir"))
module {
  func.func @main() attributes {mqt.entry_point} {
    %0 = qc.alloc : !qc.qubit
    %1 = qc.alloc : !qc.qubit
    qc.h %0 : !qc.qubit
    qc.h %0 : !qc.qubit
    qc.h %0 : !qc.qubit
    qc.ctrl(%0) targets (%arg0 = %1) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    qc.dealloc %0 : !qc.qubit
    qc.dealloc %1 : !qc.qubit
    return
  }
}

Read the output in this order:

  • module contains the program. Its func.func @main() has the mqt.entry_point attribute identifying the entry point.

  • qc.alloc allocates a qubit initialized to \(|0\rangle\). Each %... name identifies an SSA value: it is defined once, rather than reassigned.

  • !qc.qubit is the type of a qubit reference. All three qc.h operations use the same reference. The reference stays the same while the quantum state changes. This is reference semantics.

  • qc.ctrl applies the operations in its body under quantum control. The body’s %arg... names are its arguments; here the body applies X to the target qubit.

  • Braces delimit regions, which contain blocks of operations. A block ends with a terminator: qc.yield ends the controlled body, and return ends the function. qc.dealloc releases the allocated qubits.

The printed SSA names are chosen for readability. They are not stable IDs and can change after a transformation.

Make quantum data flow explicit with QCO

QC is convenient for import and export: a gate can refer to an existing qubit. For optimization, it helps to express which operation produces the quantum value used by the next operation. Convert QC to QCO to see this change:

1qco = qc.to_qco(copy=True)
2display(Code(qco.ir, language="mlir"))
module {
  func.func @main() attributes {mqt.entry_point} {
    %0 = qco.alloc : !qco.qubit
    %1 = qco.alloc : !qco.qubit
    %2 = qco.h %0 : !qco.qubit -> !qco.qubit
    %3 = qco.h %2 : !qco.qubit -> !qco.qubit
    %4 = qco.h %3 : !qco.qubit -> !qco.qubit
    %controls_out, %targets_out = qco.ctrl(%4) targets (%arg0 = %1) {
      %5 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %5 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    qco.sink %controls_out : !qco.qubit
    qco.sink %targets_out : !qco.qubit
    return
  }
}

Each qco.h now consumes one qubit value and produces a new one. Its result feeds the next Hadamard. QCO uses value semantics to make that dependency explicit. A value represents a qubit at that point in the computation; the IR does not store a simulated statevector.

QC reuses one qubit reference; QCO connects three Hadamards through successive qubit values. Removing two Hadamards reconnects the remaining value flow.

The same gates expressed through references and through quantum value flow. The names in this diagram are explanatory, not a snapshot of the printed IR.

QCO enforces linear semantics: each quantum SSA value has exactly one use. A gate transfers the value onward; it does not leave an old value available for a second operation. qco.ctrl transfers both the control and target values, even though a controlled-X does not flip its control. The final qco.sink operations consume the values at the end of their lifetimes.

This rule lets a rewrite follow the quantum data flow directly. It is not a claim that neighboring lines always act on the same qubit: follow their operands and results.

Run one optimization and check its meaning

A pass inspects or transforms IR. The canonicalize pass applies registered rules that simplify operations, including cancellation of adjacent Hadamards on the same qubit. It can apply several such rules; it is not a Hadamard-only pass.

Predict the gate count, then run it on a copy:

1optimized = qco.copy()
2optimized.run_pass_pipeline("canonicalize")
3final_qc = optimized.to_qc(copy=True)
4
5before = qc.num_gates()
6after = final_qc.num_gates()
7assert (before, after) == (4, 2)
8print(f"Gate count: {before} → {after}")
9final_qc.to_qiskit().draw("mpl")
Gate count: 4 → 2
../_images/4c5155632bf771466a4bc6449b242349b0c5f2079e95b92115ed13436c8fea31.png

The two cancelled Hadamards were redundant. But fewer gates alone do not prove correctness. For this small, unitary program, compare the full matrices:

1original_matrix = build_functionality(qco)
2optimized_matrix = build_functionality(optimized)
3np.testing.assert_allclose(optimized_matrix, original_matrix, rtol=0, atol=1e-12)
4print(
5    f"Largest matrix-entry difference: {np.max(np.abs(optimized_matrix - original_matrix)):.2e}"
6)
Largest matrix-entry difference: 0.00e+00

This checks the transformation on every input state, including phase, within floating-point tolerance. Merely obtaining the same measurement counts from \(|00\rangle\) would be a weaker check. Full matrices grow exponentially; use this experiment for these two qubits, not as a general large-program validator.

Now inspect the output state from \(|00\rangle\):

1state = simulate(optimized)
2expected = np.array([1, 0, 0, 1]) / np.sqrt(2)
3np.testing.assert_allclose(state, expected, rtol=0, atol=1e-12)
4for index, amplitude in enumerate(state):
5    print(f"|{index:02b}>: {amplitude.real:+.3f}{amplitude.imag:+.3f}j")
|00>: +0.707+0.000j
|01>: +0.000+0.000j
|10>: +0.000+0.000j
|11>: +0.707+0.000j

The vector uses basis order \(|b\,a\rangle\), with the higher-index qubit on the left. Only \(|00\rangle\) and \(|11\rangle\) have nonzero amplitudes, each \(1/\sqrt{2}\). Their measurement probabilities are \(1/2\).

Locate this pass in the full compiler

We selected one pass to explain its effect. Ordinary compile_program calls coordinate frontend preparation, conversion, optimization, and output lowering.

OpenQASM is imported into QC, converted to QCO, optimized, and converted back to QC for export. Target compilation adds placement, routing, and native synthesis before emission. Submission is a separate step.

Inspection checkpoints in the compiler. A target’s capabilities and the chosen output format also determine the required compilation steps.

The following calls expose four checkpoints. The first two do not run the configured QCO optimization pipeline, though import and conversion can perform their own preparation and cleanup.

1imported = compile_program(source, output=OutputFormat.QC_IMPORT)
2converted = compile_program(source, output=OutputFormat.QCO)
3automatic = compile_program(source, output=OutputFormat.QCO_OPTIMIZED)
4compiled = compile_program(source, output=OutputFormat.QC)
5np.testing.assert_allclose(
6    build_functionality(automatic), original_matrix, rtol=0, atol=1e-12
7)
8print(f"Imported QC gates: {imported.num_gates()}")
9print(f"Final QC gates: {compiled.num_gates()}")
Imported QC gates: 4
Final QC gates: 2

Inspect the optimized QCO and final QC below. The conversion back to reference semantics preserves the optimized program.

1display(Code(automatic.ir, language="mlir"))
2display(Code(compiled.ir, language="mlir"))

Hide code cell output

module {
  func.func @main() attributes {mqt.entry_point} {
    %0 = qco.alloc : !qco.qubit
    %1 = qco.alloc : !qco.qubit
    %2 = qco.h %0 : !qco.qubit -> !qco.qubit
    %controls_out, %targets_out = qco.ctrl(%2) targets (%arg0 = %1) {
      %3 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %3 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    qco.sink %controls_out : !qco.qubit
    qco.sink %targets_out : !qco.qubit
    return
  }
}
module {
  func.func @main() attributes {mqt.entry_point} {
    %0 = qc.alloc : !qc.qubit
    %1 = qc.alloc : !qc.qubit
    qc.h %0 : !qc.qubit
    qc.ctrl(%0) targets (%arg0 = %1) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    qc.dealloc %0 : !qc.qubit
    qc.dealloc %1 : !qc.qubit
    return
  }
}

Use qco_pipeline="canonicalize" to replace the default QCO optimization pipeline in a target-independent compilation. It does not remove the compiler’s required preparation and output stages. See the compiler guide for composing passes; the tutorial’s target compilation uses the coordinated target pipeline.

Keep Python ownership separate from quantum semantics

copy=True above preserves the source program so that we can compare stages. Without it, conversions between MLIR-backed program objects consume the source module. This avoids an implicit copy of a potentially large program.

1temporary = qc.copy()
2converted_temporary = temporary.to_qco()
3assert not temporary.is_valid
4assert converted_temporary.is_valid and qc.is_valid
5print(f"Source still owns a module: {temporary.is_valid}")
6print(f"Result owns a module: {converted_temporary.is_valid}")
Source still owns a module: False
Result owns a module: True

is_valid reports whether the Python object still owns its module. It is not an IR-verification method or a check of algorithmic correctness. Python module ownership and QCO’s linear quantum values are different concepts.

High-level compile_program preserves typed inputs by default; inplace=True allows it to consume them. Recreate inputs or use copies when comparing results interactively.

Experiment: which Hadamards cancel?

First predict the effect of zero through four consecutive Hadamards. The cell checks each variant and prints its remaining gate count:

 1for hadamards in range(5):
 2    variant = source.replace("h a;\nh a;\nh a;", "h a;\n" * hadamards)
 3    original = QCProgram.from_openqasm_str(variant).to_qco()
 4    simplified = original.copy()
 5    simplified.run_pass_pipeline("canonicalize")
 6    np.testing.assert_allclose(
 7        build_functionality(simplified),
 8        build_functionality(original),
 9        rtol=0,
10        atol=1e-12,
11    )
12    gates = simplified.to_qc(copy=True).num_gates()
13    assert gates == 1 + hadamards % 2
14    print(f"{hadamards} Hadamards → {gates} total gates")
0 Hadamards → 1 total gates
1 Hadamards → 2 total gates
2 Hadamards → 1 total gates
3 Hadamards → 2 total gates
4 Hadamards → 1 total gates
Explain the result

An even number of Hadamards acts as the identity; an odd number acts as one Hadamard. The controlled-X remains in both cases. Although it leaves \(|00\rangle\) unchanged, it acts on other possible inputs, so removing it would change the program’s full unitary.

Now replace the three Hadamards with H, Z, H. Can you still cancel the two Hadamards across Z? Predict the final state before running:

 1intervening = source.replace("h a;\nh a;\nh a;", "h a;\nz a;\nh a;")
 2intervening_qco = QCProgram.from_openqasm_str(intervening).to_qco()
 3simplified = intervening_qco.copy()
 4simplified.run_pass_pipeline("canonicalize")
 5np.testing.assert_allclose(
 6    build_functionality(simplified),
 7    build_functionality(intervening_qco),
 8    rtol=0,
 9    atol=1e-12,
10)
11np.testing.assert_allclose(simulate(simplified), [0, 0, 0, 1], rtol=0, atol=1e-12)
12print(simulate(simplified))
[0.+0.j 0.+0.j 0.+0.j 1.+0.j]
Explain the result

\(HZH = X\), not \(Z\). Starting from \(|00\rangle\), X flips a, and the controlled-X then flips b, producing \(|11\rangle\). Removing the Hadamards across Z would be incorrect. A different rewrite may use the complete identity \(HZH = X\); this is not cancellation of adjacent inverse gates.

Continue with Follow registers and control flow to see how quantum values move through registers and control flow. For operation definitions and implementation guidance, use the ‘qc’ Dialect, ‘qco’ Dialect, and MLIR development policy references.