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.

Download this notebook.

Hide code cell source

1from IPython.display import Code, display
2from qiskit.visualization import plot_distribution
3
4from mqt.core.mlir import OutputFormat, QCProgram, compile_program, sample

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:

  1. qtensor.alloc creates a tensor<3x!qco.qubit>. QTensor represents a collection of linear quantum values.

  2. qtensor.extract transfers a qubit out of the tensor. It returns both the qubit and the remaining tensor, whose extracted slot is unavailable until filled again.

  3. A gate consumes the extracted qubit and produces its successor.

  4. qtensor.insert puts that successor back and returns an updated tensor.

  5. scf.for carries the tensor through iter_args. The body receives the current tensor as a block argument and returns its successor with scf.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
../_images/d3cd6b5c4693f1d2b2178cb48334765b5a39092a28b52d388f01c61b2f9196d6.png

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")
../_images/31f0f3b6351b08a7589484ac601b91370bbc35d9576e74cf1df247e7484ba5d2.png

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:

Hide code cell source

1for test_width in (2, 4):
2    variant = ghz_source.replace(f"[{width}]", f"[{test_width}]").replace(
3        f"[1:{width - 1}]", f"[1:{test_width - 1}]"
4    )
5    counts = sample(variant, shots=shots, seed=seed)
6    assert set(counts) == {"0" * test_width, "1" * test_width}
7    assert sum(counts.values()) == shots
8    assert abs(counts["0" * test_width] / shots - 0.5) < 0.05
9    print(f"Width {test_width}: {counts}")
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")
../_images/7b423bfa5fdbbfda4d9220d65dac65b0fd441fb05fa78403610ea0f032f0353c.png

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

qco.ctrl

Quantum control qubit

Applies a coherent controlled operation, without measuring the control

qco.if

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")
../_images/83e81679f053cac9c8d99ec9fd3cea8035629a2fd0615799d0a700a18bbfd87b.png

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)
../_images/db140e79ea9c85ea58d57499e533c3dae988869028c8fbad61d8a5387087bfa1.png

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.