Repository: Qiskit/qiskit

Issue:
Barrier replacement introduces cycle with disjoint coupling map
### Environment

- **Qiskit Terra version**:  main (0.24.0
- **Python version**:  3.10
- **Operating system**:  Linux


### What is happening?

In some circumstances after #9840 when targeting backends with disjoint coupling maps if there is a barrier spanning multiple connected components on the target backend the `SabreLayout` pass will raise an error: `DAGCircuitError: 'Replacing the specified node block would introduce a cycle'`. This is caused by the pass attempting to recombine the barriers after re-assembling the combined circuit after splitting it to solve the layout and routing for the circuit, the cycle check was left on to catch this exact case where combining the barriers on re-assembly would result in an invalid dag.

### How can we reproduce the issue?

```python
import numpy as np
import rustworkx as rx

from qiskit import transpile
from qiskit.providers import BackendV2, Options
from qiskit.transpiler import Target, InstructionProperties
from qiskit.circuit.library import XGate, SXGate, RZGate, CZGate
from qiskit.circuit import Measure, Delay, Parameter

class FakeMultiChip(BackendV2):
    """Fake multi chip backend."""

    def __init__(self, degree=3):
        super().__init__(name='multi_chip')
        graph = rx.generators.directed_heavy_hex_graph(degree, bidirectional=False)
        num_qubits = len(graph) * 3
        rng = np.random.default_rng(seed=12345678942)
        rz_props = {}
        x_props = {}
        sx_props = {}
        measure_props = {}
        delay_props = {}
        self._target = Target("Fake multi-chip backend", num_qubits=num_qubits)
        for i in range(num_qubits):
            qarg = (i,)
            rz_props[qarg] = InstructionProperties(error=0.0, duration=0.0)
            x_props[qarg] = InstructionProperties(
                error=rng.uniform(1e-6, 1e-4), duration=rng.uniform(1e-8, 9e-7)
            )
            sx_props[qarg] = InstructionProperties(
                error=rng.uniform(1e-6, 1e-4), duration=rng.uniform(1e-8, 9e-7)
            )
            measure_props[qarg] = InstructionProperties(
                error=rng.uniform(1e-3, 1e-1), duration=rng.uniform(1e-8, 9e-7)
            )
            delay_props[qarg] = None
        self._target.add_instruction(XGate(), x_props)
        self._target.add_instruction(SXGate(), sx_props)
        self._target.add_instruction(RZGate(Parameter("theta")), rz_props)
        self._target.add_instruction(Measure(), measure_props)
        self._target.add_instruction(Delay(Parameter("t")), delay_props)
        cz_props = {}
        for i in range(3):
            for root_edge in graph.edge_list():
                offset = i * len(graph)
                edge = (root_edge[0] + offset, root_edge[1] + offset)
                cz_props[edge] = InstructionProperties(
                    error=rng.uniform(1e-5, 5e-3), duration=rng.uniform(1e-8, 9e-7)
                )
        self._target.add_instruction(CZGate(), cz_props)

    @property
    def target(self):
        return self._target
    
    @property
    def max_circuits(self):
        return None

    @classmethod
    def _default_options(cls):
        return Options(shots=1024)

    def run(self, circuit, **kwargs):
        raise NotImplementedError    

qc = QuantumCircuit(42)
qc.h(0)
qc.h(10)
qc.h(20)
qc.cx(0, 1)
qc.cx(0, 2)
qc.cx(0, 3)
qc.cx(0, 4)
qc.cx(0, 5)
qc.cx(0, 6)
qc.cx(0, 7)
qc.cx(0, 8)
qc.cx(0, 9)
qc.ecr(10, 11)
qc.ecr(10, 12)
qc.ecr(10, 13)
qc.ecr(10, 14)
qc.ecr(10, 15)
qc.ecr(10, 16)
qc.ecr(10, 17)
qc.ecr(10, 18)
qc.ecr(10, 19)
qc.cy(20, 21)
qc.cy(20, 22)
qc.cy(20, 23)
qc.cy(20, 24)
qc.cy(20, 25)
qc.cy(20, 26)
qc.cy(20, 27)
qc.cy(20, 28)
qc.cy(20, 29)
qc.h(30)
qc.cx(30, 31)
qc.cx(30, 32)
qc.cx(30, 33)
qc.h(34)
qc.cx(34, 35)
qc.cx(34, 36)
qc.cx(34, 37)
qc.h(38)
qc.cx(38, 39)
qc.cx(39, 40)
qc.cx(39, 41)
qc.measure_all()
res = transpile(qc, backend, seed_transpiler=42)
```

### What should happen?

The `transpile()` call should not error. The root cause is the barrier spanning across the connected components would result in a cycle in the dag.

### Any suggestions?

I was worried about this #9840 but I don't have a good solution. The first thought is on encountering a cycle we keep the dag split and just drop the UUID label which doesn't seem great. My bigger concern is that this means doing routing on the split dags is not valid and we need to apply the layout first and then run routing on the full circuit to be able to reliably do it. This has a performance overhead, but would only apply if the number of connected components > 1 so it might be worth it. We already take this hit if there is any classical data shared between components in the circuit so it might just be worth it to expand https://github.com/Qiskit/qiskit-terra/blob/33704ea61f6a3fa60612cbe4463894d2859fe9ec/qiskit/transpiler/passes/layout/sabre_layout.py to be for all circuits with > 1 connected component.


Discussion:

Resolving patch:
diff --git a/qiskit/transpiler/passes/layout/sabre_layout.py b/qiskit/transpiler/passes/layout/sabre_layout.py
--- a/qiskit/transpiler/passes/layout/sabre_layout.py
+++ b/qiskit/transpiler/passes/layout/sabre_layout.py
@@ -232,8 +232,6 @@ def run(self, dag):
         )
         initial_layout_dict = {}
         final_layout_dict = {}
-        shared_clbits = False
-        seen_clbits = set()
         for (
             layout_dict,
             final_dict,
@@ -244,20 +242,14 @@ def run(self, dag):
         ) in layout_components:
             initial_layout_dict.update({k: component_map[v] for k, v in layout_dict.items()})
             final_layout_dict.update({component_map[k]: component_map[v] for k, v in final_dict})
-            if not shared_clbits:
-                for clbit in local_dag.clbits:
-                    if clbit in seen_clbits:
-                        shared_clbits = True
-                        break
-                    seen_clbits.add(clbit)
         self.property_set["layout"] = Layout(initial_layout_dict)
         # If skip_routing is set then return the layout in the property set
         # and throwaway the extra work we did to compute the swap map.
-        # We also skip routing here if the input circuit is split over multiple
-        # connected components and there is a shared clbit between any
-        # components. We can only reliably route the full dag if there is any
-        # shared classical data.
-        if self.skip_routing or shared_clbits:
+        # We also skip routing here if there is more than one connected
+        # component we ran layout on. We can only reliably route the full dag
+        # in this case if there is any dependency between the components
+        # (typically shared classical data or barriers).
+        if self.skip_routing or len(layout_components) > 1:
             return dag
         # After this point the pass is no longer an analysis pass and the
         # output circuit returned is transformed with the layout applied


Test patch:
diff --git a/test/python/compiler/test_transpiler.py b/test/python/compiler/test_transpiler.py
--- a/test/python/compiler/test_transpiler.py
+++ b/test/python/compiler/test_transpiler.py
@@ -2274,7 +2274,7 @@ def _visit_block(circuit, qubit_mapping=None):
         )
 
     @slow_test
-    @data(1, 2, 3)
+    @data(2, 3)
     def test_six_component_circuit(self, opt_level):
         """Test input circuit with more than 1 component per backend component."""
         qc = QuantumCircuit(42)
@@ -2329,6 +2329,61 @@ def test_six_component_circuit(self, opt_level):
                 continue
             self.assertIn(qubits, self.backend.target[op_name])
 
+    def test_six_component_circuit_level_1(self):
+        """Test input circuit with more than 1 component per backend component."""
+        opt_level = 1
+        qc = QuantumCircuit(42)
+        qc.h(0)
+        qc.h(10)
+        qc.h(20)
+        qc.cx(0, 1)
+        qc.cx(0, 2)
+        qc.cx(0, 3)
+        qc.cx(0, 4)
+        qc.cx(0, 5)
+        qc.cx(0, 6)
+        qc.cx(0, 7)
+        qc.cx(0, 8)
+        qc.cx(0, 9)
+        qc.ecr(10, 11)
+        qc.ecr(10, 12)
+        qc.ecr(10, 13)
+        qc.ecr(10, 14)
+        qc.ecr(10, 15)
+        qc.ecr(10, 16)
+        qc.ecr(10, 17)
+        qc.ecr(10, 18)
+        qc.ecr(10, 19)
+        qc.cy(20, 21)
+        qc.cy(20, 22)
+        qc.cy(20, 23)
+        qc.cy(20, 24)
+        qc.cy(20, 25)
+        qc.cy(20, 26)
+        qc.cy(20, 27)
+        qc.cy(20, 28)
+        qc.cy(20, 29)
+        qc.h(30)
+        qc.cx(30, 31)
+        qc.cx(30, 32)
+        qc.cx(30, 33)
+        qc.h(34)
+        qc.cx(34, 35)
+        qc.cx(34, 36)
+        qc.cx(34, 37)
+        qc.h(38)
+        qc.cx(38, 39)
+        qc.cx(39, 40)
+        qc.cx(39, 41)
+        qc.measure_all()
+        tqc = transpile(qc, self.backend, optimization_level=opt_level, seed_transpiler=42)
+        for inst in tqc.data:
+            qubits = tuple(tqc.find_bit(x).index for x in inst.qubits)
+            op_name = inst.operation.name
+            if op_name == "barrier":
+                continue
+            self.assertIn(qubits, self.backend.target[op_name])
+
     @data(0, 1, 2, 3)
     def test_shared_classical_between_components_condition(self, opt_level):
         """Test a condition sharing classical bits between components."""
diff --git a/test/python/transpiler/test_sabre_layout.py b/test/python/transpiler/test_sabre_layout.py
--- a/test/python/transpiler/test_sabre_layout.py
+++ b/test/python/transpiler/test_sabre_layout.py
@@ -241,18 +241,9 @@ def test_dual_ghz(self):
         layout_routing_pass = SabreLayout(
             self.dual_grid_cmap, seed=123456, swap_trials=1, layout_trials=1
         )
-        out = layout_routing_pass(qc)
+        layout_routing_pass(qc)
         layout = layout_routing_pass.property_set["layout"]
         self.assertEqual([layout[q] for q in qc.qubits], [3, 1, 2, 5, 4, 6, 7, 8])
-        self.assertEqual(1, out.count_ops()["swap"])
-        edge_set = set(self.dual_grid_cmap.graph.edge_list())
-        for gate in out.data:
-            if len(gate.qubits) == 2:
-                qubits = tuple(out.find_bit(x).index for x in gate.qubits)
-                # Handle reverse edges which will be fixed by gate direction
-                # later
-                if qubits not in edge_set:
-                    self.assertIn((qubits[1], qubits[0]), edge_set)
 
     def test_dual_ghz_with_wide_barrier(self):
         """Test a basic example with 2 circuit components and 2 cmap components."""
@@ -269,18 +260,9 @@ def test_dual_ghz_with_wide_barrier(self):
         layout_routing_pass = SabreLayout(
             self.dual_grid_cmap, seed=123456, swap_trials=1, layout_trials=1
         )
-        out = layout_routing_pass(qc)
+        layout_routing_pass(qc)
         layout = layout_routing_pass.property_set["layout"]
         self.assertEqual([layout[q] for q in qc.qubits], [3, 1, 2, 5, 4, 6, 7, 8])
-        self.assertEqual(1, out.count_ops()["swap"])
-        edge_set = set(self.dual_grid_cmap.graph.edge_list())
-        for gate in out.data:
-            if len(gate.qubits) == 2:
-                qubits = tuple(out.find_bit(x).index for x in gate.qubits)
-                # Handle reverse edges which will be fixed by gate direction
-                # later
-                if qubits not in edge_set:
-                    self.assertIn((qubits[1], qubits[0]), edge_set)
 
     def test_dual_ghz_with_intermediate_barriers(self):
         """Test dual ghz circuit with intermediate barriers local to each componennt."""
@@ -299,18 +281,9 @@ def test_dual_ghz_with_intermediate_barriers(self):
         layout_routing_pass = SabreLayout(
             self.dual_grid_cmap, seed=123456, swap_trials=1, layout_trials=1
         )
-        out = layout_routing_pass(qc)
+        layout_routing_pass(qc)
         layout = layout_routing_pass.property_set["layout"]
         self.assertEqual([layout[q] for q in qc.qubits], [3, 1, 2, 5, 4, 6, 7, 8])
-        self.assertEqual(1, out.count_ops()["swap"])
-        edge_set = set(self.dual_grid_cmap.graph.edge_list())
-        for gate in out.data:
-            if len(gate.qubits) == 2:
-                qubits = tuple(out.find_bit(x).index for x in gate.qubits)
-                # Handle reverse edges which will be fixed by gate direction
-                # later
-                if qubits not in edge_set:
-                    self.assertIn((qubits[1], qubits[0]), edge_set)
 
     def test_dual_ghz_with_intermediate_spanning_barriers(self):
         """Test dual ghz circuit with barrier in the middle across components."""
@@ -328,18 +301,9 @@ def test_dual_ghz_with_intermediate_spanning_barriers(self):
         layout_routing_pass = SabreLayout(
             self.dual_grid_cmap, seed=123456, swap_trials=1, layout_trials=1
         )
-        out = layout_routing_pass(qc)
+        layout_routing_pass(qc)
         layout = layout_routing_pass.property_set["layout"]
         self.assertEqual([layout[q] for q in qc.qubits], [3, 1, 2, 5, 4, 6, 7, 8])
-        self.assertEqual(1, out.count_ops()["swap"])
-        edge_set = set(self.dual_grid_cmap.graph.edge_list())
-        for gate in out.data:
-            if len(gate.qubits) == 2:
-                qubits = tuple(out.find_bit(x).index for x in gate.qubits)
-                # Handle reverse edges which will be fixed by gate direction
-                # later
-                if qubits not in edge_set:
-                    self.assertIn((qubits[1], qubits[0]), edge_set)
 
     def test_too_large_components(self):
         """Assert trying to run a circuit with too large a connected component raises."""