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.
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")
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:
modulecontains the program. Itsfunc.func @main()has themqt.entry_pointattribute identifying the entry point.qc.allocallocates a qubit initialized to \(|0\rangle\). Each%...name identifies an SSA value: it is defined once, rather than reassigned.!qc.qubitis the type of a qubit reference. All threeqc.hoperations use the same reference. The reference stays the same while the quantum state changes. This is reference semantics.qc.ctrlapplies 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.yieldends the controlled body, andreturnends the function.qc.deallocreleases 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.
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
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.
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"))
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.