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:
After OptimizeSwapBeforeMeasure recursively processes the loop body, the body becomes effectively:
and therefore the complete transformed program behaves as:
q0 = 1
q1 = 0
measure(q0, c0)
measure(q1, c1)
which produces:
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:
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:
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:
This would specifically cover the whole-program control-flow case that was not present in the original #7642 reproducer.
Environment
Also reproduced on
2.6.0.dev0+b6f798f(b6f798f0bd88965821a54bff3785323a39c3a6c5)What is happening?
OptimizeSwapBeforeMeasurecan remove a terminalSWAPinside 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:
However,
OptimizeSwapBeforeMeasureis decorated with:@control_flow.trivial_recurseso the pass itself recursively processes the body of the
forloop as an independent DAG.Within that block, the terminal
SWAPhas only block-localDAGOutNodedescendants. 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
OptimizeSwapBeforeMeasuredid not recursively process control-flow blocks.@control_flow.trivial_recursewas added to this pass by Terra 0.22.0 along with transpiler support for control-flow circuits.How can we reproduce the issue?
Expected observed result:
The source program performs:
so the measurements produce:
After
OptimizeSwapBeforeMeasurerecursively processes the loop body, the body becomes effectively:and therefore the complete transformed program behaves as:
which produces:
The difference is deterministic.
What should happen?
OptimizeSwapBeforeMeasureshould preserve the observable semantics of the complete input program.In particular, a
DAGOutNodeinside a control-flow block represents a block boundary, not necessarily the end of the complete quantum program.Therefore, the terminal:
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:
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:
trivial_recurseconverts each control-flow block to its ownDAGCircuitand calls the pass recursively on that DAG.Second, the SWAP eligibility test currently accepts descendants that are either measurements or
DAGOutNodes: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
DAGOutNodeas 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
DAGOutNodeshould 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:
and verify that applying
OptimizeSwapBeforeMeasurepreserves the deterministic result:This would specifically cover the whole-program control-flow case that was not present in the original #7642 reproducer.