diff --git a/cirq-core/cirq/__init__.py b/cirq-core/cirq/__init__.py index 290d48ac632..8d05d13f97d 100644 --- a/cirq-core/cirq/__init__.py +++ b/cirq-core/cirq/__init__.py @@ -369,6 +369,7 @@ index_tags as index_tags, is_negligible_turn as is_negligible_turn, LineInitialMapper as LineInitialMapper, + GraphMonomorphismMapper as GraphMonomorphismMapper, MappingManager as MappingManager, map_clean_and_borrowable_qubits as map_clean_and_borrowable_qubits, map_moments as map_moments, diff --git a/cirq-core/cirq/protocols/json_test_data/spec.py b/cirq-core/cirq/protocols/json_test_data/spec.py index 4e4dbb4bafd..08a91d29f2f 100644 --- a/cirq-core/cirq/protocols/json_test_data/spec.py +++ b/cirq-core/cirq/protocols/json_test_data/spec.py @@ -91,6 +91,7 @@ # Routing utilities 'HardCodedInitialMapper', 'LineInitialMapper', + 'GraphMonomorphismMapper', 'MappingManager', 'RouteCQC', # Qubit Managers, diff --git a/cirq-core/cirq/transformers/__init__.py b/cirq-core/cirq/transformers/__init__.py index d1f6c70b7a7..672d7524e4e 100644 --- a/cirq-core/cirq/transformers/__init__.py +++ b/cirq-core/cirq/transformers/__init__.py @@ -51,6 +51,7 @@ AbstractInitialMapper as AbstractInitialMapper, HardCodedInitialMapper as HardCodedInitialMapper, LineInitialMapper as LineInitialMapper, + GraphMonomorphismMapper as GraphMonomorphismMapper, MappingManager as MappingManager, RouteCQC as RouteCQC, routed_circuit_with_mapping as routed_circuit_with_mapping, diff --git a/cirq-core/cirq/transformers/routing/__init__.py b/cirq-core/cirq/transformers/routing/__init__.py index 33d836d881c..baa3c9f2153 100644 --- a/cirq-core/cirq/transformers/routing/__init__.py +++ b/cirq-core/cirq/transformers/routing/__init__.py @@ -23,6 +23,10 @@ from cirq.transformers.routing.line_initial_mapper import LineInitialMapper as LineInitialMapper +from cirq.transformers.routing.graph_monomorphism_mapper import ( + GraphMonomorphismMapper as GraphMonomorphismMapper, +) + from cirq.transformers.routing.route_circuit_cqc import RouteCQC as RouteCQC from cirq.transformers.routing.visualize_routed_circuit import ( diff --git a/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper.py b/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper.py new file mode 100644 index 00000000000..5473ccc5d2d --- /dev/null +++ b/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper.py @@ -0,0 +1,198 @@ +# Copyright 2022 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Maps logical to physical qubits by finding a graph monomorphism into the device graph. + +This mapper builds an *interaction graph* from the circuit (logical qubits as nodes, and an edge +between two logical qubits if they participate in any 2-qubit operation). It then attempts to find +an injective mapping of logical nodes into physical nodes such that every logical edge maps to a +physical edge (i.e. a subgraph/monomorphism embedding). + +If multiple embeddings exist, it chooses the one that (heuristically) is most "central" on the +device by minimizing total distance-to-center and then (tie-break) maximizing total degree. + +If no monomorphism exists, it raises ValueError (so a router can fall back to a different strategy). +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import networkx as nx + +from cirq import protocols, value +from cirq.transformers.routing import initial_mapper + +if TYPE_CHECKING: + import cirq + + +@value.value_equality +class GraphMonomorphismMapper(initial_mapper.AbstractInitialMapper): + """Places logical qubits onto physical qubits via graph monomorphism (subgraph embedding).""" + + def __init__( + self, + device_graph: nx.Graph, + *, + max_matches: int = 5_000, + timeout_steps: Optional[int] = None, + ) -> None: + """Initializes a GraphMonomorphismMapper. + + Args: + device_graph: Device connectivity graph (physical qubits are nodes). If directed, it is + treated as undirected for the purposes of placement. + max_matches: Maximum number of candidate embeddings to consider before choosing the best + mapping found so far. + timeout_steps: Optional hard cap on internal iteration steps (additional guardrail). + """ + # For placement, treat connectivity as undirected adjacency. + # If you need strict directionality, you'd do a DiGraph monomorphism with edge constraints. + ug = nx.Graph() + ug.add_nodes_from(sorted(list(device_graph.nodes(data=True)))) + ug.add_edges_from(sorted(tuple(sorted(e)) for e in device_graph.edges)) + self.device_graph = ug + + # Center is used only as a heuristic scoring anchor. + # (nx.center returns nodes with minimum eccentricity.) + self.center = nx.center(self.device_graph)[0] + self.max_matches = int(max_matches) + self.timeout_steps = None if timeout_steps is None else int(timeout_steps) + + def _make_circuit_interaction_graph(self, circuit: cirq.AbstractCircuit) -> nx.Graph: + """Builds the circuit interaction graph from 2-qubit operations.""" + g = nx.Graph() + logical_qubits = sorted(circuit.all_qubits()) + g.add_nodes_from(logical_qubits) + + for op in circuit.all_operations(): + if protocols.num_qubits(op) != 2: + continue + q0, q1 = op.qubits + if q0 == q1: + continue + # Coalesce repeated interactions into a single simple edge. + g.add_edge(q0, q1) + + return g + + def _score_embedding( + self, logical_to_physical: dict[cirq.Qid, cirq.Qid], dist_to_center: dict[cirq.Qid, int] + ) -> tuple[int, int]: + """Scores an embedding; lower score is better. + + The score is a tuple used for lexicographic comparison: + (sum of distances to the device center, -sum of device degrees). + + Args: + logical_to_physical: Mapping from logical qubits to physical qubits. + dist_to_center: Precomputed shortest-path distance from each physical qubit to the + device center. + + Returns: + A score tuple. Lower is preferred; ties are broken by favoring higher-degree placements. + """ + total_dist = 0 + total_degree = 0 + for _, pq in logical_to_physical.items(): + total_dist += dist_to_center.get(pq, 10**9) + total_degree += self.device_graph.degree(pq) + return (total_dist, -total_degree) + + def initial_mapping(self, circuit: cirq.AbstractCircuit) -> dict[cirq.Qid, cirq.Qid]: + """Finds an initial mapping by embedding the circuit interaction graph + into the device graph. + + Args: + circuit: The input circuit with logical qubits. + + Returns: + A dictionary mapping logical qubits in the circuit (keys) to physical qubits on the + device (values). + + Raises: + ValueError: If no graph monomorphism embedding exists, or if the circuit has more qubits + than the device graph can host. + """ + circuit_g = self._make_circuit_interaction_graph(circuit) + + # Trivial fast path: no qubits. + if circuit_g.number_of_nodes() == 0: + return {} + + # If the circuit has more logical qubits than device has physical qubits, impossible. + if circuit_g.number_of_nodes() > self.device_graph.number_of_nodes(): + raise ValueError("Circuit has more qubits than the device graph can host.") + + # Precompute distances to the device center for scoring. + dist_to_center = dict(nx.single_source_shortest_path_length(self.device_graph, self.center)) + + # NetworkX subgraph isomorphism: + # GraphMatcher(G_big, G_small).subgraph_isomorphisms_iter() + # yields mappings: big_node -> small_node. + matcher = nx.algorithms.isomorphism.GraphMatcher(self.device_graph, circuit_g) + + best_map: Optional[dict[cirq.Qid, cirq.Qid]] = None + best_score: Optional[tuple[int, int]] = None + + steps = 0 + matches_seen = 0 + + for big_to_small in matcher.subgraph_isomorphisms_iter(): + # Optional guardrails. + steps += 1 + if self.timeout_steps is not None and steps > self.timeout_steps: + break + + # Invert to get logical -> physical. + # big_to_small: physical -> logical + logical_to_physical = {lq: pq for pq, lq in big_to_small.items()} + + # Ensure all logical nodes are mapped (they should be, but be defensive). + if len(logical_to_physical) != circuit_g.number_of_nodes(): + continue + + score = self._score_embedding(logical_to_physical, dist_to_center) + if best_score is None or score < best_score: + best_score = score + best_map = logical_to_physical + + matches_seen += 1 + if matches_seen >= self.max_matches: + break + + if best_map is None: + raise ValueError( + "No graph monomorphism embedding found for circuit interaction graph " + "into device graph." + ) + + return best_map + + def _value_equality_values_(self): + return ( + tuple(self.device_graph.nodes), + tuple(self.device_graph.edges), + self.max_matches, + self.timeout_steps, + ) + + def __repr__(self): + graph_type = type(self.device_graph).__name__ + return ( + "cirq.GraphMonomorphismMapper(" + f"nx.{graph_type}({dict(self.device_graph.adjacency())}), " + f"max_matches={self.max_matches}, timeout_steps={self.timeout_steps})" + ) diff --git a/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper_test.py b/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper_test.py new file mode 100644 index 00000000000..e9fe62dcb83 --- /dev/null +++ b/cirq-core/cirq/transformers/routing/graph_monomorphism_mapper_test.py @@ -0,0 +1,246 @@ +# Copyright 2022 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Iterator +from typing import cast + +import networkx as nx +import pytest + +import cirq + +# Unit tests: keep N small and VF2 bounded. +_MAX_MATCHES = 1 # stop at first embedding +_TIMEOUT_STEPS = 2_000 # hard cap search work + + +def construct_star_circuit_5q(): + # Interaction graph edges: (1-3), (2-3), (4-3). Center has degree 3. + return cirq.Circuit( + [ + cirq.Moment(cirq.CNOT(cirq.NamedQubit("1"), cirq.NamedQubit("3"))), + cirq.Moment(cirq.CNOT(cirq.NamedQubit("2"), cirq.NamedQubit("3"))), + cirq.Moment(cirq.CNOT(cirq.NamedQubit("4"), cirq.NamedQubit("3"))), + cirq.Moment(cirq.X(cirq.NamedQubit("5"))), + ] + ) + + +def construct_path_circuit(k: int): + q = cirq.LineQubit.range(k) + return cirq.Circuit(cirq.CNOT(q[i], q[i + 1]) for i in range(k - 1)) + + +def _interaction_graph(circuit: cirq.AbstractCircuit) -> nx.Graph: + g = nx.Graph() + g.add_nodes_from(sorted(circuit.all_qubits())) + for op in circuit.all_operations(): + if cirq.num_qubits(op) != 2: + continue + a, b = op.qubits + if a != b: + g.add_edge(a, b) + return g + + +def _assert_is_monomorphism( + circuit: cirq.AbstractCircuit, device_graph: nx.Graph, mapping: dict[cirq.Qid, cirq.Qid] +) -> None: + # Injective + total. + assert len(set(mapping.values())) == len(mapping.values()) + assert set(mapping.keys()) == set(circuit.all_qubits()) + + # Edge-preserving. + cg = _interaction_graph(circuit) + dg = device_graph.to_undirected() if nx.is_directed(device_graph) else device_graph + for u, v in cg.edges: + pu, pv = mapping[u], mapping[v] + assert dg.has_edge(pu, pv) + + +def test_path_embeds_on_small_grid() -> None: + circuit = construct_path_circuit(10) + device = cirq.testing.construct_grid_device(4, 4) # 16 physical + g = device.metadata.nx_graph + + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + mapping = mapper.initial_mapping(circuit) + + _assert_is_monomorphism(circuit, g, mapping) + device.validate_circuit(circuit.transform_qubits(mapping)) + + +def test_star_embeds_on_small_grid() -> None: + circuit = construct_star_circuit_5q() + device = cirq.testing.construct_grid_device(4, 4) + g = device.metadata.nx_graph + + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + mapping = mapper.initial_mapping(circuit) + + _assert_is_monomorphism(circuit, g, mapping) + device.validate_circuit(circuit.transform_qubits(mapping)) + + +def test_star_fails_on_ring() -> None: + circuit = construct_star_circuit_5q() + device = cirq.testing.construct_ring_device(10, directed=True) + g = device.metadata.nx_graph + + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=500) + with pytest.raises(ValueError, match="No graph monomorphism embedding found"): + mapper.initial_mapping(circuit) + + +def test_path_embeds_on_ring() -> None: + circuit = construct_path_circuit(6) + device = cirq.testing.construct_ring_device(10, directed=True) + g = device.metadata.nx_graph + + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + mapping = mapper.initial_mapping(circuit) + + _assert_is_monomorphism(circuit, g, mapping) + device.validate_circuit(circuit.transform_qubits(mapping)) + + +def test_more_logical_than_physical_fails_fast() -> None: + circuit = construct_path_circuit(17) # 17 logical + device = cirq.testing.construct_grid_device(4, 4) # 16 physical + g = device.metadata.nx_graph + + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + with pytest.raises(ValueError, match="more qubits than the device graph can host"): + mapper.initial_mapping(circuit) + + +def test_empty_circuit_returns_empty_mapping() -> None: + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + assert mapper.initial_mapping(cirq.Circuit()) == {} + + +def test_make_interaction_graph_skips_self_edges() -> None: + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + + class TwoQubitSelfOp(cirq.Operation): + def __init__(self, q: cirq.Qid) -> None: + self._q = q + + @property + def qubits(self) -> tuple[cirq.Qid, cirq.Qid]: + return (self._q, self._q) + + def with_qubits(self, *new_qubits: cirq.Qid) -> TwoQubitSelfOp: + assert len(new_qubits) == 1 + return TwoQubitSelfOp(new_qubits[0]) + + def _num_qubits_(self) -> int: + return 2 + + q = cirq.NamedQubit("q") + a, b = cirq.NamedQubit("a"), cirq.NamedQubit("b") + + ops = [TwoQubitSelfOp(q), cirq.CZ(a, b)] + qubits = {q, a, b} + + class FakeCircuit: + def all_qubits(self): + return qubits + + def all_operations(self): + return iter(ops) + + # cover with_qubits for incremental coverage + _ = TwoQubitSelfOp(q).with_qubits(q) + + cg = mapper._make_circuit_interaction_graph(cast(cirq.AbstractCircuit, FakeCircuit())) + + assert a in cg.nodes and b in cg.nodes and q in cg.nodes + assert cg.has_edge(a, b) + assert not cg.has_edge(q, q) + assert cg.degree(q) == 0 + + +def test_timeout_steps_breaks_out_and_raises(monkeypatch: pytest.MonkeyPatch) -> None: + # Forces coverage of: + # if self.timeout_steps is not None and steps > self.timeout_steps: break + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=10_000, timeout_steps=0) + + circuit = construct_path_circuit(2) + + class FakeMatcher: + def subgraph_isomorphisms_iter(self) -> Iterator[dict[cirq.Qid, cirq.Qid]]: + # Infinite-ish generator; timeout should stop it immediately. + while True: + yield {} + + monkeypatch.setattr( + nx.algorithms.isomorphism, "GraphMatcher", lambda *_args, **_kw: FakeMatcher() + ) + + with pytest.raises(ValueError, match="No graph monomorphism embedding found"): + mapper.initial_mapping(circuit) + + +def test_defensive_incomplete_mapping_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + # Covers: + # if len(logical_to_physical) != circuit_g.number_of_nodes(): continue + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=10, timeout_steps=_TIMEOUT_STEPS) + + circuit = construct_path_circuit(2) # 2 logical nodes + + class FakeMatcher: + def subgraph_isomorphisms_iter(self) -> Iterator[dict[cirq.Qid, cirq.Qid]]: + # physical -> logical mapping with only 1 logical node + # -> after inversion it's incomplete + yield {cirq.LineQubit(0): cirq.LineQubit(0)} + + monkeypatch.setattr( + nx.algorithms.isomorphism, "GraphMatcher", lambda *_args, **_kw: FakeMatcher() + ) + + with pytest.raises(ValueError, match="No graph monomorphism embedding found"): + mapper.initial_mapping(circuit) + + +def test_score_embedding_uses_default_large_distance() -> None: + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=_MAX_MATCHES, timeout_steps=_TIMEOUT_STEPS) + + # Pick an actual physical node from the graph. + pq = next(iter(mapper.device_graph.nodes)) + lq = cirq.NamedQubit("lq") + + # dist_to_center missing pq -> should fall back to 10**9. + score = mapper._score_embedding({lq: pq}, dist_to_center={}) + assert score[0] >= 10**9 + + +def test_value_equality_values_and_repr() -> None: + g = cirq.testing.construct_grid_device(2, 2).metadata.nx_graph + mapper = cirq.GraphMonomorphismMapper(g, max_matches=123, timeout_steps=456) + + # Covers _value_equality_values_ explicitly. + vals = mapper._value_equality_values_() + assert vals[2] == 123 + assert vals[3] == 456 + + # Covers __repr__ (and keeps the existing equivalent repr check). + cirq.testing.assert_equivalent_repr(mapper, setup_code="import cirq\nimport networkx as nx")