Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Starting layout analysis pass for sabre #10829

Merged
merged 20 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions qiskit/transpiler/passes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
Layout2qDistance
EnlargeWithAncilla
FullAncillaAllocation
SabrePreLayout

Routing
=======
Expand Down Expand Up @@ -193,6 +194,7 @@
from .layout import Layout2qDistance
from .layout import EnlargeWithAncilla
from .layout import FullAncillaAllocation
from .layout import SabrePreLayout

# routing
from .routing import BasicSwap
Expand Down
1 change: 1 addition & 0 deletions qiskit/transpiler/passes/layout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@
from .layout_2q_distance import Layout2qDistance
from .enlarge_with_ancilla import EnlargeWithAncilla
from .full_ancilla_allocation import FullAncillaAllocation
from .sabre_pre_layout import SabrePreLayout
217 changes: 217 additions & 0 deletions qiskit/transpiler/passes/layout/sabre_pre_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2023.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.

"""Creating Sabre starting layouts."""

import itertools

from qiskit.transpiler import CouplingMap, Target, AnalysisPass, TranspilerError
from qiskit.transpiler.passes.layout.vf2_layout import VF2Layout
from qiskit._accelerate.error_map import ErrorMap


class SabrePreLayout(AnalysisPass):
"""Choose a starting layout to use for additional Sabre layout trials.

Property Set Values Written
---------------------------

``sabre_starting_layouts`` (``list[Layout]``)
An optional list of :class:`~.Layout` objects to use for additional Sabre layout trials.

"""

def __init__(
self,
coupling_map,
max_distance=2,
error_rate=0.1,
max_trials_vf2=100,
call_limit_vf2=None,
improve_layout=True,
):
"""SabrePreLayout initializer.

The pass works by augmenting the coupling map with more and more "extra" edges
until VF2 succeeds to find a perfect graph isomorphism. More precisely, the
augmented coupling map contains edges between nodes that are within a given
distance ``d`` in the original coupling map, and the value of ``d`` is increased
until an isomorphism is found.

Intuitively, a better layout involves fewer extra edges. The pass also optionally
minimizes the number of extra edges involved in the layout until a local minimum
is found. This involves removing extra edges and running VF2 to see if an
isomorphism still exists.

Args:
coupling_map (Union[CouplingMap, Target]): directed graph representing the
original coupling map or a target modelling the backend (including its
connectivity).
max_distance (int): the maximum distance to consider for augmented coupling maps.
error_rate (float): the error rate to assign to the "extra" edges. A non-zero
error rate prioritizes VF2 to choose original edges over extra edges.
max_trials_vf2 (int): specifies the maximum number of VF2 trials. A larger number
allows VF2 to explore more layouts, eventually choosing the one with the smallest
error rate.
call_limit_vf2 (int): limits each call to VF2 by bounding the number of VF2 state visits.
improve_layout (bool): whether to improve the layout by minimizing the number of
extra edges involved. This might be time-consuming as this requires additional
VF2 calls.

Raises:
TranspilerError: At runtime, if neither ``coupling_map`` or ``target`` are provided.
"""

self.max_distance = max_distance
self.error_rate = error_rate
self.max_trials_vf2 = max_trials_vf2
self.call_limit_vf2 = call_limit_vf2
self.improve_layout = improve_layout

if isinstance(coupling_map, Target):
self.target = coupling_map
self.coupling_map = self.target.build_coupling_map()
else:
self.target = None
self.coupling_map = coupling_map

super().__init__()

def run(self, dag):
"""Run the SabrePreLayout pass on `dag`.

The discovered starting layout is written to the property set
value ``sabre_starting_layouts``.

Args:
dag (DAGCircuit): DAG to create starting layout for.
"""

if self.coupling_map is None:
raise TranspilerError(
"SabrePreLayout requires coupling_map to be used with either"
"CouplingMap or a Target."
)

starting_layout = None
cur_distance = 1
while cur_distance <= self.max_distance:
augmented_map, augmented_error_map = self._add_extra_edges(cur_distance)
pass_ = VF2Layout(
augmented_map,
seed=0,
max_trials=self.max_trials_vf2,
call_limit=self.call_limit_vf2,
)
pass_.property_set["vf2_avg_error_map"] = augmented_error_map
pass_.run(dag)

if "layout" in pass_.property_set:
starting_layout = pass_.property_set["layout"]
break

cur_distance += 1

if cur_distance > 1 and starting_layout is not None:
# optionally improve starting layout
if self.improve_layout:
starting_layout = self._minimize_extra_edges(dag, starting_layout)
# write discovered layout into the property set
if "sabre_starting_layouts" not in self.property_set:
self.property_set["sabre_starting_layouts"] = [starting_layout]
else:
self.property_set["sabre_starting_layouts"].append(starting_layout)

def _add_extra_edges(self, distance):
"""Augments the coupling map with extra edges that connect nodes ``distance``
apart in the original graph. The extra edges are assigned errors allowing VF2
to prioritize real edges over extra edges.
"""
nq = len(self.coupling_map.graph)
augmented_coupling_map = CouplingMap()
augmented_coupling_map.graph = self.coupling_map.graph.copy()
augmented_error_map = ErrorMap(nq)

for (x, y) in itertools.combinations(self.coupling_map.graph.node_indices(), 2):
d = self.coupling_map.distance(x, y)
if 1 < d <= distance:
error_rate = 1 - ((1 - self.error_rate) ** d)
augmented_coupling_map.add_edge(x, y)
augmented_error_map.add_error((x, y), error_rate)
augmented_coupling_map.add_edge(y, x)
augmented_error_map.add_error((y, x), error_rate)

return augmented_coupling_map, augmented_error_map

def _get_extra_edges_used(self, dag, layout):
"""Returns the set of extra edges involved in the layout."""
extra_edges_used = set()
virtual_bits = layout.get_virtual_bits()
for node in dag.two_qubit_ops():
p0 = virtual_bits[node.qargs[0]]
p1 = virtual_bits[node.qargs[1]]
if self.coupling_map.distance(p0, p1) > 1:
extra_edge = (p0, p1) if p0 < p1 else (p1, p0)
extra_edges_used.add(extra_edge)
return extra_edges_used

def _find_layout(self, dag, edges):
"""Checks if there is a layout for a given set of edges."""
cm = CouplingMap(edges)
pass_ = VF2Layout(cm, seed=0, max_trials=1, call_limit=self.call_limit_vf2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is a bit heavy weight we're basically we just need to call rustworkx.is_subgraph_isomorphic(cm_graph, im_graph, id_order=True, induced=False, call_limit=self.call_limit_vf2) here without all the pass machinery to determine if a reduced edge list is valid or not.

For a first implementation I think this is fine, because there is probably some larger refinement we'll want to do since we do need a layout with the minimized edge list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am leaving this as is for now, but sure this is something to rethink in the future.

pass_.run(dag)
return pass_.property_set.get("layout", None)

def _minimize_extra_edges(self, dag, starting_layout):
"""Minimizes the set of extra edges involved in the layout. This iteratively
removes extra edges from the coupling map and uses VF2 to check if a layout
still exists. This is reasonably efficiently as it only looks for a local
minimum.
"""
# compute the set of edges in the original coupling map
real_edges = []
for (x, y) in itertools.combinations(self.coupling_map.graph.node_indices(), 2):
d = self.coupling_map.distance(x, y)
if d == 1:
real_edges.append((x, y))

best_layout = starting_layout

# keeps the set of "necessary" extra edges: without a necessary edge
# a layout no longer exists
extra_edges_necessary = []

extra_edges_unprocessed_set = self._get_extra_edges_used(dag, starting_layout)

while extra_edges_unprocessed_set:
# choose some unprocessed edge
edge_chosen = next(iter(extra_edges_unprocessed_set))
extra_edges_unprocessed_set.remove(edge_chosen)

# check if a layout still exists without this edge
layout = self._find_layout(
dag, real_edges + extra_edges_necessary + list(extra_edges_unprocessed_set)
)

if layout is None:
# without this edge the layout either does not exist or is too hard to find
extra_edges_necessary.append(edge_chosen)

else:
# this edge is not necessary, furthermore we can trim the set of edges to examine based
# in the edges involved in the layout.
extra_edges_unprocessed_set = self._get_extra_edges_used(dag, layout).difference(
set(extra_edges_necessary)
)
best_layout = layout
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we minimize the edge list we don't take into account noise really and just return the first edge found? Since _find_layout sets max_trials=1. Is there value in running multiple trials on the output of minimization to take into account error rates?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good question. On the one hand, I believe that some part of the effort of looking for extra solutions at this stage would be wasted, as the top-level algorithm would proceed to removing more edges and calling this function again. On the other hand, it does seem a good idea to take the noise into account here as well. However, I do think that the effort here should be smaller than the effort in the main call. A possible solution would be to add yet another argument to the run function, something like improve_layout_max_trials_vf2 (and, while we are at this, also improve_layout_call_limit_vf2). Please tell me if you are agree with this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not sure either way. In general for sabre I don't think noise awareness buys us very much because we have VF2PostLayout which factors in the whole circuit after routing. The place where this is different is to try and avoid the extra edges we've added to the coupling map. But I think for right now this is probably fine, we can always refine the pass more and add extra options in a follow up.


return best_layout
37 changes: 37 additions & 0 deletions releasenotes/notes/add-sabre-starting-layout-7e151b7abb8a6c13.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
features:
- |
Added a new analysis :class:`.SabrePreLayout` pass that creates a starting
layout for :class:`.SabreLayout`, writing the layout into the property set
value ``sabre_starting_layouts``.

The pass works by augmenting the coupling map with more and more "extra" edges
until :class:`.VF2Layout` succeeds to find a perfect graph isomorphism.
More precisely, the augmented coupling map contains edges between nodes that are
within a given distance ``d`` in the original coupling map, and the value of ``d``
is increased until an isomorphism is found. The pass also optionally minimizes
the number of extra edges involved in the layout until a local minimum is found.
This involves removing extra edges and calling :class:`.VF2Layout` to check if
an isomorphism still exists.

Here is an example of calling the :class:`.SabrePreLayout` before :class:`.SabreLayout`::

import math
from qiskit.transpiler import CouplingMap, PassManager
from qiskit.circuit.library import EfficientSU2
from qiskit.transpiler.passes import SabrePreLayout, SabreLayout

qc = EfficientSU2(16, entanglement='circular', reps=6, flatten=True)
qc.assign_parameters([math.pi / 2] * len(qc.parameters), inplace=True)
qc.measure_all()

coupling_map = CouplingMap.from_heavy_hex(7)

pm = PassManager(
[
SabrePreLayout(coupling_map=coupling_map),
SabreLayout(coupling_map),
]
)

pm.run(qc)
44 changes: 44 additions & 0 deletions test/python/transpiler/test_sabre_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@

import unittest

import math

from qiskit import QuantumRegister, QuantumCircuit
from qiskit.circuit.library import EfficientSU2
from qiskit.transpiler import CouplingMap, AnalysisPass, PassManager
from qiskit.transpiler.passes import SabreLayout, DenseLayout
from qiskit.transpiler.exceptions import TranspilerError
Expand All @@ -24,6 +27,8 @@
from qiskit.providers.fake_provider import FakeAlmaden, FakeAlmadenV2
from qiskit.providers.fake_provider import FakeKolkata
from qiskit.providers.fake_provider import FakeMontreal
from qiskit.transpiler.passes.layout.sabre_pre_layout import SabrePreLayout
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager


class TestSabreLayout(QiskitTestCase):
Expand Down Expand Up @@ -389,5 +394,44 @@ def test_with_partial_layout(self):
self.assertEqual([layout[q] for q in qc.qubits], [3, 1, 2, 5, 4, 6, 7, 8])


class TestSabrePreLayout(QiskitTestCase):
"""Tests the SabreLayout pass with starting layout created by SabrePreLayout."""

def setUp(self):
super().setUp()
circuit = EfficientSU2(16, entanglement="circular", reps=6, flatten=True)
circuit.assign_parameters([math.pi / 2] * len(circuit.parameters), inplace=True)
circuit.measure_all()
self.circuit = circuit
self.coupling_map = CouplingMap.from_heavy_hex(7)

def test_starting_layout(self):
"""Test that a starting layout is created and looks as expected."""
pm = PassManager(
[
SabrePreLayout(coupling_map=self.coupling_map),
SabreLayout(self.coupling_map, seed=123456, swap_trials=1, layout_trials=1),
]
)
pm.run(self.circuit)
layout = pm.property_set["layout"]
self.assertEqual(
[layout[q] for q in self.circuit.qubits],
[30, 98, 104, 36, 103, 35, 65, 28, 61, 91, 22, 92, 23, 93, 62, 99],
)

def test_integration_with_pass_manager(self):
"""Tests SabrePreLayoutIntegration with the rest of PassManager pipeline."""
backend = FakeAlmadenV2()
pm = generate_preset_pass_manager(1, backend, seed_transpiler=0)
pm.pre_layout = PassManager([SabrePreLayout(backend.target)])
qct = pm.run(self.circuit)
qct_initial_layout = qct.layout.initial_layout
self.assertEqual(
[qct_initial_layout[q] for q in self.circuit.qubits],
[1, 6, 5, 10, 11, 12, 16, 17, 18, 13, 14, 9, 8, 3, 2, 0],
)


if __name__ == "__main__":
unittest.main()
Loading