Follow registers and control flow¶
A quantum program can keep a register, repeat a block, and use a measurement to choose what happens next. How does the compiler represent these features while preserving QCO’s linear quantum values?
This second compiler tutorial runs independently with the tutorial setup. Start with Follow a program through the compiler for an introduction to QC and QCO.
Grow a Bell state into a GHZ state¶
For \(n\) qubits, a GHZ state is \((|0\cdots0\rangle + |1\cdots1\rangle)/\sqrt{2}\). Apply H to the first qubit, then a controlled-X from that qubit to each of the others.
Use a register and loop instead of writing every controlled-X by hand.
Change width from 3 to 2 or 4 and rerun the following cells. Predict both the
loop’s iteration count and the possible measurement outcomes.
1width = 3
2shots = 4096
3seed = 17
4assert 2 <= width <= 4
5
6ghz_source = f"""OPENQASM 3.0;
7include "stdgates.inc";
8qubit[{width}] q;
9bit[{width}] result;
10h q[0];
11for int i in [1:{width - 1}] {{
12 cx q[0], q[i];
13}}
14result = measure q;
15"""
16
17ghz_qc = QCProgram.from_openqasm_str(ghz_source)
OpenQASM ranges include both endpoints: [1:2] visits 1 and 2. The loop
therefore applies width - 1 controlled-X gates during execution. The last
statement measures each qubit into the corresponding output bit.
Inspect the register in QC¶
Read the allocation, the loads inside the loop, and the measurement stores:
1display(Code(ghz_qc.ir, language="mlir"))
module {
func.func @main() -> !cbit.reg<3> attributes {mqt.entry_point} {
%alloc = memref.alloc() {mqt.register_name = "q"} : memref<3x!qc.qubit>
%0 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<3>
%c0 = arith.constant 0 : index
%1 = memref.load %alloc[%c0] : memref<3x!qc.qubit>
qc.h %1 : !qc.qubit
%c1 = arith.constant 1 : index
%c1_0 = arith.constant 1 : index
%c2 = arith.constant 2 : index
%c1_1 = arith.constant 1 : index
%c3 = arith.constant 3 : index
scf.for %arg0 = %c1 to %c3 step %c1_0 {
%8 = arith.index_cast %arg0 : index to i64
%c0_8 = arith.constant 0 : index
%9 = memref.load %alloc[%c0_8] : memref<3x!qc.qubit>
%10 = memref.load %alloc[%arg0] : memref<3x!qc.qubit>
qc.ctrl(%9) targets (%arg1 = %10) {
qc.x %arg1 : !qc.qubit
qc.yield
} : {!qc.qubit}, {!qc.qubit}
}
%c0_2 = arith.constant 0 : index
%2 = memref.load %alloc[%c0_2] : memref<3x!qc.qubit>
%3 = qc.measure %2 : !qc.qubit -> i1
%c0_3 = arith.constant 0 : index
cbit.store %3, %0[%c0_3] : !cbit.reg<3>
%c1_4 = arith.constant 1 : index
%4 = memref.load %alloc[%c1_4] : memref<3x!qc.qubit>
%5 = qc.measure %4 : !qc.qubit -> i1
%c1_5 = arith.constant 1 : index
cbit.store %5, %0[%c1_5] : !cbit.reg<3>
%c2_6 = arith.constant 2 : index
%6 = memref.load %alloc[%c2_6] : memref<3x!qc.qubit>
%7 = qc.measure %6 : !qc.qubit -> i1
%c2_7 = arith.constant 2 : index
cbit.store %7, %0[%c2_7] : !cbit.reg<3>
memref.dealloc %alloc : memref<3x!qc.qubit>
return %0 : !cbit.reg<3>
}
}
memref<3x!qc.qubit> holds three qubit references in the default example.
memref.load retrieves one of those references; a quantum gate operates on the
referenced qubit. This is the register counterpart of scalar QC reference
semantics.
The output has type !cbit.reg<3>. CBit represents classical-bit registers
shared by QC and QCO. qc.measure produces an i1 measurement result;
cbit.store writes it into the register that main returns. The imported
register’s undefined initializer is safe here because the program writes every
bit before returning it.
scf.for is MLIR’s structured counted loop. Its upper bound is exclusive,
so the compiler translates OpenQASM’s inclusive range accordingly. An
arith.constant creates a classical constant; index is MLIR’s type for
indexing and iteration in this loop.
Follow the register through QCO¶
Predict what must change: if each quantum value has exactly one use, how can successive loop iterations work on the same register?
1ghz_qco = ghz_qc.to_qco(copy=True)
2display(Code(ghz_qco.ir, language="mlir"))
module {
func.func @main() -> !cbit.reg<3> attributes {mqt.entry_point} {
%c3 = arith.constant 3 : index
%c2 = arith.constant 2 : index
%c1 = arith.constant 1 : index
%c0 = arith.constant 0 : index
%c3_0 = arith.constant 3 : index
%0 = qtensor.alloc(%c3_0) {mqt.register_name = "q"} : tensor<3x!qco.qubit>
%1 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<3>
%out_tensor, %result = qtensor.extract %0[%c0] : tensor<3x!qco.qubit>
%2 = qco.h %result : !qco.qubit -> !qco.qubit
%3 = qtensor.insert %2 into %out_tensor[%c0] : tensor<3x!qco.qubit>
%4 = scf.for %arg0 = %c1 to %c3 step %c1 iter_args(%arg1 = %3) -> (tensor<3x!qco.qubit>) {
%out_tensor_12, %result_13 = qtensor.extract %arg1[%c0] : tensor<3x!qco.qubit>
%out_tensor_14, %result_15 = qtensor.extract %out_tensor_12[%arg0] : tensor<3x!qco.qubit>
%controls_out, %targets_out = qco.ctrl(%result_13) targets (%arg2 = %result_15) {
%10 = qco.x %arg2 : !qco.qubit -> !qco.qubit
qco.yield %10 : !qco.qubit
} : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
%8 = qtensor.insert %targets_out into %out_tensor_14[%arg0] : tensor<3x!qco.qubit>
%9 = qtensor.insert %controls_out into %8[%c0] : tensor<3x!qco.qubit>
scf.yield %9 : tensor<3x!qco.qubit>
}
%out_tensor_1, %result_2 = qtensor.extract %4[%c0] : tensor<3x!qco.qubit>
%qubit_out, %result_3 = qco.measure %result_2 : !qco.qubit
%5 = qtensor.insert %qubit_out into %out_tensor_1[%c0] : tensor<3x!qco.qubit>
cbit.store %result_3, %1[%c0] : !cbit.reg<3>
%out_tensor_4, %result_5 = qtensor.extract %5[%c1] : tensor<3x!qco.qubit>
%qubit_out_6, %result_7 = qco.measure %result_5 : !qco.qubit
%6 = qtensor.insert %qubit_out_6 into %out_tensor_4[%c1] : tensor<3x!qco.qubit>
cbit.store %result_7, %1[%c1] : !cbit.reg<3>
%out_tensor_8, %result_9 = qtensor.extract %6[%c2] : tensor<3x!qco.qubit>
%qubit_out_10, %result_11 = qco.measure %result_9 : !qco.qubit
%7 = qtensor.insert %qubit_out_10 into %out_tensor_8[%c2] : tensor<3x!qco.qubit>
cbit.store %result_11, %1[%c2] : !cbit.reg<3>
qtensor.dealloc %7 : tensor<3x!qco.qubit>
return %1 : !cbit.reg<3>
}
}
Read the quantum register’s path through the program:
qtensor.alloccreates atensor<3x!qco.qubit>. QTensor represents a collection of linear quantum values.qtensor.extracttransfers a qubit out of the tensor. It returns both the qubit and the remaining tensor, whose extracted slot is unavailable until filled again.A gate consumes the extracted qubit and produces its successor.
qtensor.insertputs that successor back and returns an updated tensor.scf.forcarries the tensor throughiter_args. The body receives the current tensor as a block argument and returns its successor withscf.yield. The loop result holds the tensor after the final iteration.
Extraction does not copy a quantum state. Both the extracted qubit and the remaining tensor follow linear semantics. A loop must carry its quantum state through the iteration arguments rather than repeatedly capturing an earlier value from outside the body.
qco.measure produces two results: the post-measurement qubit value and the
classical outcome. The qubit must still be inserted back into its tensor; the
classical result is stored in the CBit register. Classical values do not follow
the quantum exactly-one-use rule.
Distinguish a loop body from executed gates¶
The inspection methods count gate operations in the entry-point IR. A loop body is counted once, regardless of its trip count:
1static_gates = ghz_qc.num_gates()
2static_cx = ghz_qc.num_two_qubit_gates()
3assert static_gates == 2 and static_cx == 1
4print(f"Static IR: {static_gates} gates, including {static_cx} controlled-X")
5print(
6 f"This program executes: {1 + width - 1} gates, including {width - 1} controlled-X"
7)
Static IR: 2 gates, including 1 controlled-X
This program executes: 3 gates, including 2 controlled-X
The runtime count here follows directly from a loop with constant bounds. It is
not a general estimate provided by num_gates(). Branches and other loops can
make runtime work depend on measurement outcomes or classical inputs.
Compare the same program after explicit loop unrolling:
1unrolled = ghz_qco.copy()
2unrolled.unroll_quantum_loops()
3unrolled_qc = unrolled.to_qc()
4assert unrolled_qc.num_two_qubit_gates() == width - 1
5print(f"Controlled-X operations after unrolling: {unrolled_qc.num_two_qubit_gates()}")
6unrolled_qc.to_qiskit().draw("mpl")
Controlled-X operations after unrolling: 2
Unrolling duplicates the body for the known iterations. It changes the representation, not the algorithm. Keeping loops can keep IR compact; some output formats or target-address requirements need the iterations exposed. The target notebook explains how output and hardware constraints guide compilation.
Check the observable result¶
The QCO sampler interprets supported structured programs directly. With 4096 shots, approximately half the results should be all zeros and half all ones:
1ghz_counts = sample(ghz_qco, shots=shots, seed=seed)
2zeros, ones = "0" * width, "1" * width
3assert set(ghz_counts) == {zeros, ones}
4assert sum(ghz_counts.values()) == shots
5assert abs(ghz_counts[zeros] / shots - 0.5) < 0.05
6plot_distribution(ghz_counts, title=f"{width}-qubit GHZ outputs")
The keys describe the returned classical register, with its highest-index bit on the left. The sampler reports measured outputs; it does not display a statevector. A fixed nonzero seed makes this experiment reproducible, but statistical checks should not depend on a particular histogram.
Experiment: change the register width
Try width = 2 and width = 4 in the parameter cell and rerun the GHZ section.
The structured body still contains one controlled-X, but it executes once or
three times. The histogram still has two outcomes, now with two or four bits.
These checks execute both variants during the documentation build:
Width 2: {'00': 2058, '11': 2038}
Width 4: {'0000': 2094, '1111': 2002}
Use a measurement to choose the next gate¶
A classical correction prepares zero even when the first measurement is random. Start with H, measure, and apply X only if the outcome is 1. What should a second measurement return?
1feedback_source = """OPENQASM 3.0;
2include "stdgates.inc";
3qubit q;
4bit outcome;
5h q;
6outcome = measure q;
7if (outcome) {
8 x q;
9}
10outcome = measure q;
11"""
12
13feedback_qc = QCProgram.from_openqasm_str(feedback_source)
14feedback_qc.to_qiskit().draw("mpl")
The output register is overwritten by the second measurement. The first result controls the correction but is not a second returned output bit.
Inspect the branch in QCO:
1feedback_qco = feedback_qc.to_qco(copy=True)
2display(Code(feedback_qco.ir, language="mlir"))
module {
func.func @main() -> !cbit.reg<1> attributes {mqt.entry_point} {
%0 = qco.alloc : !qco.qubit
%1 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "outcome"} : !cbit.reg<1>
%2 = qco.h %0 : !qco.qubit -> !qco.qubit
%qubit_out, %result = qco.measure %2 : !qco.qubit
%c0 = arith.constant 0 : index
cbit.store %result, %1[%c0] : !cbit.reg<1>
%c0_0 = arith.constant 0 : index
%3 = cbit.load %1[%c0_0] : !cbit.reg<1>
%linearResults = qco.if %3 args(%arg0 = %qubit_out) -> (!qco.qubit) {
%4 = qco.x %arg0 : !qco.qubit -> !qco.qubit
qco.yield %4 : !qco.qubit
} else args(%arg0 = %qubit_out) {
qco.yield %arg0 : !qco.qubit
}
%qubit_out_1, %result_2 = qco.measure %linearResults : !qco.qubit
%c0_3 = arith.constant 0 : index
cbit.store %result_2, %1[%c0_3] : !cbit.reg<1>
qco.sink %qubit_out_1 : !qco.qubit
return %1 : !cbit.reg<1>
}
}
qco.if receives a classical condition and transfers the post-measurement qubit
into the selected branch. The true branch applies X; the false branch forwards
the qubit unchanged. Each branch yields the qubit value that execution should
use next. The second measurement consumes the branch result.
Operation |
Condition |
What happens |
|---|---|---|
|
Quantum control qubit |
Applies a coherent controlled operation, without measuring the control |
|
Classical value |
Executes one branch and carries its quantum values to the result |
Generic scf.if can represent classical branching, but quantum branches use
qco.if to express these ownership transfers. SCF loops carry quantum values
through their iteration arguments, as in the GHZ example.
1corrected_counts = sample(feedback_qco, shots=shots, seed=seed)
2assert corrected_counts == {"0": shots}
3plot_distribution(corrected_counts, title="Final measurement after correction")
Every shot returns zero. Use sampling for this experiment: the convenience
simulate() function that returns a dense statevector does not support
mid-circuit feedback. See the DD guide for lower-level
simulation interfaces.
Experiment: remove the correction
Delete the if block, or run the variant below. The second measurement repeats
the first outcome because nothing changes the measured qubit in between. Its
outcome is random across shots, even though the two measurements within a shot
agree.
1uncorrected_source = feedback_source.replace("if (outcome) {\n x q;\n}\n", "")
2uncorrected_counts = sample(uncorrected_source, shots=shots, seed=seed)
3assert set(uncorrected_counts) == {"0", "1"}
4assert sum(uncorrected_counts.values()) == shots
5assert abs(uncorrected_counts["0"] / shots - 0.5) < 0.05
6plot_distribution(
7 [corrected_counts, uncorrected_counts], legend=["Correction", "No correction"]
8)
Continue with Compile for hardware constraints to compile against explicit hardware constraints. For larger structured examples, explore iterative QPE and repeat until success. The ‘qtensor’ Dialect, ‘cbit’ Dialect, and OpenQASM input and output references describe the operations and supported input forms in detail.