diff --git a/bqskit/passes/control/predicates/distributed.py b/bqskit/passes/control/predicates/distributed.py new file mode 100644 index 000000000..a1ceb2275 --- /dev/null +++ b/bqskit/passes/control/predicates/distributed.py @@ -0,0 +1,26 @@ +"""This module implements the DistributedPredicate class.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from bqskit.passes.control.predicate import PassPredicate + +if TYPE_CHECKING: + from bqskit.compiler.passdata import PassData + from bqskit.ir.circuit import Circuit + +_logger = logging.getLogger(__name__) + + +class DistributedPredicate(PassPredicate): + """ + The DistributedPredicate class. + + The DistributedPredicate returns true if the targeted machine is distributed + across multiple chips. + """ + + def get_truth_value(self, circuit: Circuit, data: PassData) -> bool: + """Call this predicate, see :class:`PassPredicate` for more info.""" + return data.model.coupling_graph.is_distributed() diff --git a/bqskit/passes/mapping/pam.py b/bqskit/passes/mapping/pam.py index 549ab2b6b..92127a2c3 100644 --- a/bqskit/passes/mapping/pam.py +++ b/bqskit/passes/mapping/pam.py @@ -277,7 +277,7 @@ def _get_best_perm( cg: CouplingGraph, F: set[CircuitPoint], pi: list[int], - D: list[list[int]], + D: list[list[float]], E: set[CircuitPoint], qudits: Sequence[int], ) -> tuple[tuple[int, ...], Circuit, tuple[int, ...]]: @@ -366,7 +366,7 @@ def _score_perm( circuit: Circuit, F: set[CircuitPoint], pi: list[int], - D: list[list[int]], + D: list[list[float]], perm: tuple[Sequence[int], Sequence[int]], E: set[CircuitPoint], ) -> float: diff --git a/bqskit/passes/mapping/sabre.py b/bqskit/passes/mapping/sabre.py index b257fda84..27d52c0da 100644 --- a/bqskit/passes/mapping/sabre.py +++ b/bqskit/passes/mapping/sabre.py @@ -363,7 +363,7 @@ def _get_best_swap( circuit: Circuit, F: set[CircuitPoint], E: set[CircuitPoint], - D: list[list[int]], + D: list[list[float]], cg: CouplingGraph, pi: list[int], decay: list[float], @@ -416,7 +416,7 @@ def _score_swap( circuit: Circuit, F: set[CircuitPoint], pi: list[int], - D: list[list[int]], + D: list[list[float]], swap: tuple[int, int], decay: list[float], E: set[CircuitPoint], @@ -475,7 +475,7 @@ def _get_distance( self, logical_qudits: Sequence[int], pi: list[int], - D: list[list[int]], + D: list[list[float]], ) -> float: """Calculate the expected number of swaps to connect logical qudits.""" min_term = np.inf @@ -493,7 +493,7 @@ def _uphill_swaps( logical_qudits: Sequence[int], cg: CouplingGraph, pi: list[int], - D: list[list[int]], + D: list[list[float]], ) -> Iterator[tuple[int, int]]: """Yield the swaps necessary to bring some of the qudits together.""" center_qudit = min( diff --git a/bqskit/qis/graph.py b/bqskit/qis/graph.py index 7a7ae7c30..54e0664ec 100644 --- a/bqskit/qis/graph.py +++ b/bqskit/qis/graph.py @@ -6,11 +6,10 @@ import logging from random import shuffle from typing import Any -from typing import cast from typing import Collection from typing import Iterable from typing import Iterator -from typing import List +from typing import Mapping from typing import Tuple from typing import TYPE_CHECKING from typing import Union @@ -23,7 +22,7 @@ from bqskit.ir.location import CircuitLocation from bqskit.ir.location import CircuitLocationLike from bqskit.utils.typing import is_integer -from bqskit.utils.typing import is_iterable +from bqskit.utils.typing import is_iterable, is_mapping, is_real_number _logger = logging.getLogger(__name__) @@ -33,31 +32,143 @@ class CouplingGraph(Collection[Tuple[int, int]]): def __init__( self, - graph: Iterable[tuple[int, int]], + graph: CouplingGraphLike, num_qudits: int | None = None, + remote_edges: Iterable[tuple[int, int]] = [], + default_weight: float = 1.0, + default_remote_weight: float = 100.0, + edge_weights_overrides: Mapping[tuple[int, int], float] = {}, ) -> None: + """ + Construct a new CouplingGraph. + + Args: + graph (CouplingGraphLike): The undirected graph edges. + + num_qudits (int | None): The number of qudits in the graph. If + None, the number of qudits is inferred from the maximum seen + in the edge list. (Default: None) + + remote_edges (Iterable[tuple[int, int]]): The edges that cross + QPU chip boundaries. Distributed QPUs will have remote links + connect them. Notes, remote edges must specified both in + `graph` and here. (Default: []) + + default_weight (float): The default weight of an edge in the + graph. (Default: 1.0) + + default_remote_weight (float): The default weight of a remote + edge in the graph. (Default: 100.0) + + edge_weights_overrides (Mapping[tuple[int, int], float]): A mapping + of edges to their weights. These override the defaults on + a case-by-case basis. (Default: {}) + + Raises: + ValueError: If `num_qudits` is too small for the edges in `graph`. + + ValueError: If `num_qudits` is less than zero. + + ValueError: If any edge in `remote_edges` is not in `graph`. + + ValueError: If any edge in `edge_weights_overrides` is not in + `graph`. + """ + if not CouplingGraph.is_valid_coupling_graph(graph): + raise TypeError('Invalid coupling graph.') + + if num_qudits is not None and not is_integer(num_qudits): + raise TypeError( + 'Expected integer for num_qudits,' + f' got {type(num_qudits)}', + ) + + if num_qudits is not None and num_qudits < 0: + raise ValueError( + 'Expected nonnegative num_qudits,' + f' got {num_qudits}.', + ) + + if not CouplingGraph.is_valid_coupling_graph(remote_edges): + raise TypeError('Invalid remote links.') + + if any(edge not in graph for edge in remote_edges): + invalids = [e for e in remote_edges if e not in graph] + raise ValueError( + f'Remote links {invalids} not in graph.' + ' All remote links must also be specified in the graph input.', + ) + + if not is_real_number(default_weight): + raise TypeError( + 'Expected integer for default_weight,' + f' got {type(default_weight)}', + ) + + if not is_real_number(default_remote_weight): + raise TypeError( + 'Expected integer for default_remote_weight,' + f' got {type(default_remote_weight)}', + ) + + if not is_mapping(edge_weights_overrides): + raise TypeError( + 'Expected mapping for edge_weights_overrides,' + f' got {type(edge_weights_overrides)}', + ) + + if any( + not is_real_number(v) + for v in edge_weights_overrides.values() + ): + invalids = [ + v for v in edge_weights_overrides.values() + if not is_real_number(v) + ] + raise TypeError( + 'Expected integer values for edge_weights_overrides,' + f' got non-integer values: {invalids}.', + ) + + if any(edge not in graph for edge in edge_weights_overrides): + invalids = [ + e for e in edge_weights_overrides + if e not in graph + ] + raise ValueError( + f'Edges {invalids} from edge_weights_overrides are not in ' + 'the graph. All edge_weights_overrides must also be ' + 'specified in the graph input.', + ) + if isinstance(graph, CouplingGraph): self.num_qudits: int = graph.num_qudits self._edges: set[tuple[int, int]] = graph._edges + self._remote_edges: set[tuple[int, int]] = graph._remote_edges self._adj: list[set[int]] = graph._adj + self._mat: list[list[float]] = graph._mat + self.default_weight: float = graph.default_weight + self.default_remote_weight: float = graph.default_remote_weight return - if not CouplingGraph.is_valid_coupling_graph(graph): - raise TypeError('Invalid coupling graph.') - - self._edges = {g if g[0] <= g[1] else (g[1], g[0]) for g in graph} + calc_num_qudits = 0 + for q1, q2 in graph: + calc_num_qudits = max(calc_num_qudits, max(q1, q2)) + calc_num_qudits += 1 - calced_num_qudits = 0 - for q1, q2 in self._edges: - calced_num_qudits = max(calced_num_qudits, max(q1, q2)) - calced_num_qudits += 1 + if num_qudits is not None and calc_num_qudits > num_qudits: + raise ValueError( + 'Edges between invalid qudits or num_qudits too small.', + ) - if num_qudits is None: - self.num_qudits = calced_num_qudits - elif calced_num_qudits > num_qudits: - raise ValueError('Edges between invalid qudits.') - else: - self.num_qudits = num_qudits + self.num_qudits = calc_num_qudits if num_qudits is None else num_qudits + self._edges = {g if g[0] <= g[1] else (g[1], g[0]) for g in graph} + self._remote_edges = { + e if e[0] <= e[1] else (e[1], e[0]) + for e in remote_edges + } + self.default_weight = default_weight + self.default_remote_weight = default_remote_weight self._adj = [set() for _ in range(self.num_qudits)] for q1, q2 in self._edges: @@ -69,8 +180,77 @@ def __init__( for _ in range(self.num_qudits) ] for q1, q2 in self._edges: - self._mat[q1][q2] = 1 - self._mat[q2][q1] = 1 + self._mat[q1][q2] = default_weight + self._mat[q2][q1] = default_weight + + for q1, q2 in self._remote_edges: + self._mat[q1][q2] = default_remote_weight + self._mat[q2][q1] = default_remote_weight + + for (q1, q2), weight in edge_weights_overrides.items(): + self._mat[q1][q2] = weight + self._mat[q2][q1] = weight + + def get_qpu_to_qudit_map(self) -> list[list[int]]: + """Return a mapping of QPU indices to qudit indices.""" + if not hasattr(self, '_qpu_to_qudit'): + seen = set() + self._qpu_to_qudit = [] + for qudit in range(self.num_qudits): + if qudit in seen: + continue + qpu = [] + frontier = {qudit} + while len(frontier) > 0: + node = frontier.pop() + qpu.append(node) + seen.add(node) + for neighbor in self._adj[node]: + if (node, neighbor) in self._remote_edges: + continue + if (neighbor, node) in self._remote_edges: + continue + if neighbor not in seen: + frontier.add(neighbor) + self._qpu_to_qudit.append(qpu) + return self._qpu_to_qudit + + def is_distributed(self) -> bool: + """Return true if the graph represents multiple connected QPUs.""" + return len(self._remote_edges) > 0 + + def qpu_count(self) -> int: + """Return the number of connected QPUs.""" + return len(self.get_qpu_to_qudit_map()) + + def get_individual_qpu_graphs(self) -> list[CouplingGraph]: + """Return a list of individual QPU graphs.""" + if not self.is_distributed(): + return [self] + + qpu_to_qudit = self.get_qpu_to_qudit_map() + return [self.get_subgraph(qpu) for qpu in qpu_to_qudit] + + def get_qudit_to_qpu_map(self) -> list[int]: + """Return a mapping of qudit indices to QPU indices.""" + qpu_to_qudit = self.get_qpu_to_qudit_map() + qudit_to_qpu = {} + for qpu, qudits in enumerate(qpu_to_qudit): + for qudit in qudits: + qudit_to_qpu[qudit] = qpu + return list(qudit_to_qpu.values()) + + def get_qpu_connectivity(self) -> list[set[int]]: + """Return the adjacency list of the QPUs.""" + qpu_to_qudit = self.get_qpu_to_qudit_map() + qudit_to_qpu = self.get_qudit_to_qpu_map() + qpu_adj: list[set[int]] = [set() for _ in range(len(qpu_to_qudit))] + for q1, q2 in self._remote_edges: + qpu1 = qudit_to_qpu[q1] + qpu2 = qudit_to_qpu[q2] + qpu_adj[qpu1].add(qpu2) + qpu_adj[qpu2].add(qpu1) + return qpu_adj def is_fully_connected(self) -> bool: """Return true if the graph is fully connected.""" @@ -150,12 +330,12 @@ def __repr__(self) -> str: def get_qudit_degrees(self) -> list[int]: return [len(l) for l in self._adj] - def all_pairs_shortest_path(self) -> list[list[int]]: + def all_pairs_shortest_path(self) -> list[list[float]]: """ Calculate all pairs shortest path matrix using Floyd-Warshall. Returns: - D (list[list[int]]): D[i][j] is the length of the shortest + D (list[list[float]]): D[i][j] is the length of the shortest path from i to j. """ D = copy.deepcopy(self._mat) @@ -163,7 +343,7 @@ def all_pairs_shortest_path(self) -> list[list[int]]: for i in range(self.num_qudits): for j in range(self.num_qudits): D[i][j] = min(D[i][j], D[i][k] + D[k][j]) - return cast(List[List[int]], D) + return D def get_shortest_path_tree(self, source: int) -> list[tuple[int, ...]]: """Return shortest path from `source` to every node in `self`.""" diff --git a/tests/qis/test_graph.py b/tests/qis/test_graph.py index 5a205e8ee..158238959 100644 --- a/tests/qis/test_graph.py +++ b/tests/qis/test_graph.py @@ -1,9 +1,217 @@ """This module tests the CouplingGraph class.""" from __future__ import annotations +from typing import Any + import pytest from bqskit.qis.graph import CouplingGraph +from bqskit.qis.graph import CouplingGraphLike + + +def test_coupling_graph_init_valid() -> None: + # Test with valid inputs + graph = {(0, 1), (1, 2), (2, 3)} + num_qudits = 4 + remote_edges = [(1, 2)] + default_weight = 1.0 + default_remote_weight = 10.0 + edge_weights_overrides = {(1, 2): 0.5} + + coupling_graph = CouplingGraph( + graph, + num_qudits, + remote_edges, + default_weight, + default_remote_weight, + edge_weights_overrides, + ) + + assert coupling_graph.num_qudits == num_qudits + assert coupling_graph._edges == graph + assert coupling_graph._remote_edges == set(remote_edges) + assert coupling_graph.default_weight == default_weight + assert coupling_graph.default_remote_weight == default_remote_weight + assert all( + coupling_graph._mat[q1][q2] == weight + for (q1, q2), weight in edge_weights_overrides.items() + ) + + +@pytest.mark.parametrize( + 'graph, num_qudits, remote_edges, default_weight, default_remote_weight,' + ' edge_weights_overrides, expected_exception', + [ + # Invalid graph + (None, 4, [], 1.0, 100.0, {}, TypeError), + # num_qudits is not an integer + ({(0, 1)}, '4', [], 1.0, 100.0, {}, TypeError), + # num_qudits is negative + ({(0, 1)}, -1, [], 1.0, 100.0, {}, ValueError), + # Invalid remote_edges + ({(0, 1)}, 4, None, 1.0, 100.0, {}, TypeError), + # Remote edge not in graph + ({(0, 1)}, 4, [(1, 2)], 1.0, 100.0, {}, ValueError), + # Invalid default_weight + ({(0, 1)}, 4, [], '1.0', 100.0, {}, TypeError), + # Invalid default_remote_weight + ({(0, 1)}, 4, [], 1.0, '100.0', {}, TypeError), + # Invalid edge_weights_overrides + ({(0, 1)}, 4, [], 1.0, 100.0, None, TypeError), + # Non-integer value in edge_weights_overrides + ({(0, 1)}, 4, [], 1.0, 100.0, {(0, 1): '0.5'}, TypeError), + # Edge in edge_weights_overrides not in graph + ({(0, 1)}, 4, [], 1.0, 100.0, {(1, 2): 0.5}, ValueError), + ], +) +def test_coupling_graph_init_invalid( + graph: CouplingGraphLike, + num_qudits: Any, + remote_edges: Any, + default_weight: Any, + default_remote_weight: Any, + edge_weights_overrides: Any, + expected_exception: Exception, +) -> None: + with pytest.raises(expected_exception): + CouplingGraph( + graph, + num_qudits, + remote_edges, + default_weight, + default_remote_weight, + edge_weights_overrides, + ) + + +def test_get_qpu_to_qudit_map_single_qpu() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + expected_map = [[0, 1, 2, 3]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_get_qpu_to_qudit_map_multiple_qpus() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + expected_map = [[0, 1], [2, 3]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_get_qpu_to_qudit_map_disconnected() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (3, 4)], remote_edges=[(1, 2)]) + expected_map = [[0, 1], [2], [3, 4]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_get_qpu_to_qudit_map_empty_graph() -> None: + graph = CouplingGraph([]) + expected_map = [[0]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_get_qpu_to_qudit_map_complex_topology() -> None: + graph = CouplingGraph( + [(0, 1), (1, 2), (0, 2), (2, 5), (3, 4), (4, 5), (3, 5)], + remote_edges=[(2, 5)], + ) + expected_map = [[0, 1, 2], [3, 4, 5]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_get_qudit_to_qpu_map_three_qpu() -> None: + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)], + remote_edges=[(2, 3), (5, 6)], + ) + expected_map = [[0, 1, 2], [3, 4, 5], [6, 7]] + assert graph.get_qpu_to_qudit_map() == expected_map + + +def test_is_distributed() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + assert not graph.is_distributed() + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + assert graph.is_distributed() + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + assert graph.is_distributed() + + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3)], + remote_edges=[(1, 2), (2, 3)], + ) + assert graph.is_distributed() + + +def test_qpu_count() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + assert graph.qpu_count() == 1 + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + assert graph.qpu_count() == 2 + + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3)], + remote_edges=[(1, 2), (2, 3)], + ) + assert graph.qpu_count() == 3 + + graph = CouplingGraph([]) + assert graph.qpu_count() == 1 + + +def test_get_individual_qpu_graphs() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + qpus = graph.get_individual_qpu_graphs() + assert len(qpus) == 1 + assert qpus[0] == graph + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + qpus = graph.get_individual_qpu_graphs() + assert len(qpus) == 2 + assert qpus[0] == CouplingGraph([(0, 1)]) + assert qpus[1] == CouplingGraph([(0, 1)]) + + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3)], + remote_edges=[(1, 2), (2, 3)], + ) + qpus = graph.get_individual_qpu_graphs() + assert len(qpus) == 3 + assert qpus[0] == CouplingGraph([(0, 1)]) + assert qpus[1] == CouplingGraph([]) + assert qpus[2] == CouplingGraph([]) + + +def test_get_qudit_to_qpu_map() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + assert graph.get_qudit_to_qpu_map() == [0, 0, 0, 0] + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + assert graph.get_qudit_to_qpu_map() == [0, 0, 1, 1] + + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3)], + remote_edges=[(1, 2), (2, 3)], + ) + assert graph.get_qudit_to_qpu_map() == [0, 0, 1, 2] + + graph = CouplingGraph([]) + assert graph.get_qudit_to_qpu_map() == [0] + + +def test_get_qpu_connectivity() -> None: + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)]) + assert graph.get_qpu_connectivity() == [set()] + + graph = CouplingGraph([(0, 1), (1, 2), (2, 3)], remote_edges=[(1, 2)]) + assert graph.get_qpu_connectivity() == [{1}, {0}] + + graph = CouplingGraph( + [(0, 1), (1, 2), (2, 3)], + remote_edges=[(1, 2), (2, 3)], + ) + assert graph.get_qpu_connectivity() == [{1}, {0, 2}, {1}] class TestGraphGetSubgraphsOfSize: