Skip to content

OptimizeSwapBeforeMeasure can drop observable terminal SWAPs inside control-flow blocks #16853

Description

Environment

  • Qiskit version: 2.5.2
    Also reproduced on 2.6.0.dev0+b6f798f (b6f798f0bd88965821a54bff3785323a39c3a6c5)
  • Python version: 3.12
  • Operating system: Linux

What is happening?

OptimizeSwapBeforeMeasure can remove a terminal SWAP inside a control-flow block even when the swapped quantum state is subsequently observed outside that block.

This changes the observable semantics of a complete input program.

This is related to #7642, but the trigger here is different from the subcomponent-compilation case discussed there.

In #7642, the circuit was transpiled as an incomplete subcomponent and measurements were appended afterward. The discussion there noted that transpilation of such subcomponents was not a supported use, and that a terminal SWAP at the end of a complete program would not itself cause an observable semantic difference.

The reproducer below does not transpile or optimize a subcomponent separately. The complete program, including the measurements after the control-flow operation, is passed to the pass manager at once:

X(q0)

for _ in range(1):
    SWAP(q0, q1)

measure(q0, c0)
measure(q1, c1)

However, OptimizeSwapBeforeMeasure is decorated with:

@control_flow.trivial_recurse

so the pass itself recursively processes the body of the for loop as an independent DAG.

Within that block, the terminal SWAP has only block-local DAGOutNode descendants. The current eligibility check therefore accepts it and removes it, even though those output nodes represent the boundary of the control-flow block rather than the end of the complete program.

As a result, the measurements outside the block observe a different quantum state.

Historically, #7642 was reported against Qiskit Terra 0.19.1. At that point OptimizeSwapBeforeMeasure did not recursively process control-flow blocks. @control_flow.trivial_recurse was added to this pass by Terra 0.22.0 along with transpiler support for control-flow circuits.

How can we reproduce the issue?

import qiskit
from qiskit import QuantumCircuit
from qiskit.providers.basic_provider import BasicSimulator
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import (
    OptimizeSwapBeforeMeasure,
    UnrollForLoops,
)


def for_body_operations(circuit):
    for instruction in circuit.data:
        if instruction.operation.name == "for_loop":
            return [
                block_instruction.operation.name
                for block_instruction in instruction.operation.blocks[0].data
            ]
    raise RuntimeError("for_loop not found")


def operations(circuit):
    return [instruction.operation.name for instruction in circuit.data]


source = QuantumCircuit(2, 2)

# q0 = 1, q1 = 0.
source.x(0)

# This loop executes exactly once.
# Its terminal SWAP is observable by the measurements outside the block.
with source.for_loop(range(1)):
    source.swap(0, 1)

source.measure(0, 0)
source.measure(1, 1)

transformed = PassManager(
    [OptimizeSwapBeforeMeasure()]
).run(source.copy())

print("Qiskit:", qiskit.__version__)
print("Before body:", for_body_operations(source))
print("After body: ", for_body_operations(transformed))

# Unroll the one-iteration loop only so BasicSimulator can directly execute
# both resulting circuits.
source_flat = PassManager(
    [UnrollForLoops()]
).run(source)

transformed_flat = PassManager(
    [UnrollForLoops()]
).run(transformed)

print("Before flat:", operations(source_flat))
print("After flat: ", operations(transformed_flat))

backend = BasicSimulator()

before_counts = backend.run(
    source_flat,
    shots=1024,
    seed_simulator=12345,
).result().get_counts()

after_counts = backend.run(
    transformed_flat,
    shots=1024,
    seed_simulator=12345,
).result().get_counts()

print("Before counts:", before_counts)
print("After counts: ", after_counts)

assert for_body_operations(source) == ["swap"]
assert for_body_operations(transformed) == []

assert before_counts == {"10": 1024}
assert after_counts == {"01": 1024}

Expected observed result:

Before body: ['swap']
After body:  []

Before flat: ['x', 'swap', 'measure', 'measure']
After flat:  ['x', 'measure', 'measure']

Before counts: {'10': 1024}
After counts:  {'01': 1024}

The source program performs:

q0 = 1
q1 = 0

SWAP(q0, q1)

q0 = 0
q1 = 1

so the measurements produce:

c1c0 = 10

After OptimizeSwapBeforeMeasure recursively processes the loop body, the body becomes effectively:

skip

and therefore the complete transformed program behaves as:

q0 = 1
q1 = 0

measure(q0, c0)
measure(q1, c1)

which produces:

c1c0 = 01

The difference is deterministic.

What should happen?

OptimizeSwapBeforeMeasure should preserve the observable semantics of the complete input program.

In particular, a DAGOutNode inside a control-flow block represents a block boundary, not necessarily the end of the complete quantum program.

Therefore, the terminal:

SWAP(q0, q1)

inside the loop cannot simply be removed when the qubit values remain live after the block.

For this reproducer, the transformed circuit should still produce:

{'10': 1024}

The pass may either preserve the SWAP or propagate the resulting permutation correctly across the control-flow boundary, but it should not silently turn the block-local SWAP into skip.

Any suggestions?

The current implementation combines two behaviors that appear unsafe when used together.

First, the pass recursively processes every control-flow block:

@control_flow.trivial_recurse
def run(self, dag):
    ...

trivial_recurse converts each control-flow block to its own DAGCircuit and calls the pass recursively on that DAG.

Second, the SWAP eligibility test currently accepts descendants that are either measurements or DAGOutNodes:

final_successor = []

for successor in dag.descendants(swap):
    final_successor.append(
        isinstance(successor, DAGOutNode)
        or (
            isinstance(successor, DAGOpNode)
            and isinstance(successor.op, Measure)
        )
    )

if all(final_successor):
    ...
    dag.remove_op_node(swap)

For a SWAP at the end of a control-flow block, its descendants can consist only of the block's DAGOutNodes. all(final_successor) is therefore true even though there are no measurements in the block.

The measurement-rewriting layer is empty, and the SWAP is simply removed.

At the top level, treating a DAGOutNode as the end of a complete program was the behavior discussed in #7642. Inside a control-flow block, however, the same assumption does not hold: the output qubits remain live in the enclosing program.

A conservative fix would be to distinguish whole-program output nodes from control-flow-block output nodes. A SWAP whose elimination relies on a block-local DAGOutNode should not be removed unless its resulting qubit permutation is correctly propagated through the control-flow boundary.

The simplest safe behavior for recursively processed blocks may be to retain such terminal SWAPs.

A regression test could use:

X(q0)

for _ in range(1):
    SWAP(q0, q1)

measure(q0, c0)
measure(q1, c1)

and verify that applying OptimizeSwapBeforeMeasure preserves the deterministic result:

c1c0 = 10

This would specifically cover the whole-program control-flow case that was not present in the original #7642 reproducer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions