From c67df63d148c11c3ee07085807ae7b4e00467467 Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Sun, 27 Apr 2025 21:55:23 -0400 Subject: [PATCH 1/6] Adding trasnform After some trails, the naive approach is looking better in large circuits --- pennylane/transforms/__init__.py | 1 + pennylane/transforms/optimization/__init__.py | 1 + .../transforms/optimization/qubit_mapping.py | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 pennylane/transforms/optimization/qubit_mapping.py diff --git a/pennylane/transforms/__init__.py b/pennylane/transforms/__init__.py index e958d761036..6aa42753bf1 100644 --- a/pennylane/transforms/__init__.py +++ b/pennylane/transforms/__init__.py @@ -325,6 +325,7 @@ def circuit(params): undo_swaps, pattern_matching, pattern_matching_optimization, + qubit_mapping, ) from .qmc import apply_controlled_Q, quantum_monte_carlo from .unitary_to_rot import unitary_to_rot diff --git a/pennylane/transforms/optimization/__init__.py b/pennylane/transforms/optimization/__init__.py index 8251d4dbaf6..47f655e0c24 100644 --- a/pennylane/transforms/optimization/__init__.py +++ b/pennylane/transforms/optimization/__init__.py @@ -23,3 +23,4 @@ from .single_qubit_fusion import single_qubit_fusion from .undo_swaps import undo_swaps from .pattern_matching import pattern_matching, pattern_matching_optimization +from .qubit_mapping import qubit_mapping \ No newline at end of file diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py new file mode 100644 index 00000000000..3501b329848 --- /dev/null +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -0,0 +1,64 @@ +import pennylane as qml +from pennylane.transforms import transform +from pennylane.tape import QuantumScript +import networkx as nx +from typing import Dict, List, Optional, Tuple + +@transform +def qubit_mapping( + tape, + graph: Dict[int, List[int]], + init_mapping: Optional[Dict[int, int]] = None +) -> Tuple[List[QuantumScript], callable]: + """ + Fast, shortest-path-only qubit mapping: + - Builds a NetworkX graph once. + - Maintains a mapping dict, defaulting to identity. + - For each 2-qubit gate, finds the BFS shortest path, inserts SWAPs along it, + updates the mapping, then emits the remapped gate. + - Single-qubit and measurement wires are just renamed. + """ + # 1) Build NX graph once + G_nx = nx.Graph() + for u, nbrs in graph.items(): + for v in nbrs: + G_nx.add_edge(u, v) + + # 2) Initialize logical→physical mapping + if init_mapping is None: + wires = {w for op in tape.operations for w in op.wires} + mapping = {q: q for q in wires} + else: + mapping = init_mapping.copy() + + new_ops = [] + # 3) Process operations + for op in tape.operations: + w = list(op.wires) + if len(w) == 2: + q1, q2 = w + p1, p2 = mapping[q1], mapping[q2] + # find shortest physical path + path = nx.shortest_path(G_nx, p1, p2) + # insert SWAPs along path (except final adjacency) + for u, v in zip(path, path[1:]): + new_ops.append(qml.SWAP(wires=[u, v])) + # update mapping by swapping the two logical qubits + inv = {phys: log for log, phys in mapping.items()} + l_u, l_v = inv[u], inv[v] + mapping[l_u], mapping[l_v] = v, u + # now the qubits are adjacent: emit the two-qubit gate + new_ops.append(op.map_wires({q1: mapping[q1], q2: mapping[q2]})) + + else: + # single- or multi-qubit: just remap wires + wire_map = {q: mapping[q] for q in w} + new_ops.append(op.map_wires(wire_map)) + + # 4) Remap measurements + new_meas = [ + m.map_wires({q: mapping[q] for q in m.wires}) + for m in tape.measurements + ] + + return [QuantumScript(new_ops, new_meas)], lambda results: results[0] \ No newline at end of file From bb53a8f7af675bc171b33c6d10331620f728dc63 Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Tue, 29 Apr 2025 19:23:14 -0400 Subject: [PATCH 2/6] homemade trick --- .../transforms/optimization/qubit_mapping.py | 188 ++++++++++++++---- 1 file changed, 148 insertions(+), 40 deletions(-) diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py index 3501b329848..e9bd1bd2897 100644 --- a/pennylane/transforms/optimization/qubit_mapping.py +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -11,54 +11,162 @@ def qubit_mapping( init_mapping: Optional[Dict[int, int]] = None ) -> Tuple[List[QuantumScript], callable]: """ - Fast, shortest-path-only qubit mapping: - - Builds a NetworkX graph once. - - Maintains a mapping dict, defaulting to identity. - - For each 2-qubit gate, finds the BFS shortest path, inserts SWAPs along it, - updates the mapping, then emits the remapped gate. - - Single-qubit and measurement wires are just renamed. + Hybrid routing transform: + + Implements CNOT routing by selectively swapping control and target + qubits toward optimal split points on the shortest path, then using a + remote meet-in-the-middle CNOT block on the remaining subpath. + + Steps: + 1) Build the physical connectivity graph. + 2) Initialize logical→physical mapping (identity by default). + 3) Precompute all-pairs shortest paths and distances. + 4) Determine the next logical partner for each qubit at each CNOT index. + 5) For each operation: + - If non-adjacent CNOT: + a) Find shortest physical path of length d+1. + b) Let mid = floor(d/2). Search k1 in [0..mid], k2 in [mid..d], k2>k1 + minimizing: dist(path[k1], next_partner_of_control) + + dist(path[k2], next_partner_of_target). + c) Apply k1 SWAPs on the control end (path[0..k1]) and k2..d SWAPs + on the target end (path[d..k2]), updating mapping. + d) Run the meet-in-the-middle CNOT block on the subpath + path[k1..k2] (inclusive). + - Else if other 2-qubit gate: SWAP-based routing + native gate. + - Else: rename single-qubit ops by mapping. + 6) Remap measurements. """ - # 1) Build NX graph once - G_nx = nx.Graph() - for u, nbrs in graph.items(): - for v in nbrs: - G_nx.add_edge(u, v) + # 1) Build physical graph + phys = nx.Graph() + for q, nbrs in graph.items(): + phys.add_edges_from((q, nbr) for nbr in nbrs) - # 2) Initialize logical→physical mapping + # 2) Initialize mapping if init_mapping is None: - wires = {w for op in tape.operations for w in op.wires} - mapping = {q: q for q in wires} + logicals = {w for op in tape.operations for w in op.wires} + mapping = {q: q for q in logicals} else: mapping = init_mapping.copy() - new_ops = [] - # 3) Process operations - for op in tape.operations: + # 3) Precompute shortest paths and distances + spaths = dict(nx.all_pairs_shortest_path(phys)) + dist = {u: {v: len(p)-1 for v,p in targets.items()} for u,targets in spaths.items()} + path = {(u,v): p for u,targets in spaths.items() for v,p in targets.items()} + + # 4) Precompute next logical partner per CNOT + ops_list = list(tape.operations) + future: Dict[int, List[Tuple[int,int]]] = {q: [] for op in ops_list for q in op.wires} + for idx, op in enumerate(ops_list): + if op.name == 'CNOT': + c, t = op.wires + future[c].append((idx, t)) + future[t].append((idx, c)) + next_partner: List[Dict[int, Optional[int]]] = [{} for _ in ops_list] + for idx in range(len(ops_list)): + for q in mapping: + # find the smallest j>idx where q participates + part = None + for j, p in future[q]: + if j > idx: + part = p + break + next_partner[idx][q] = part + + new_ops: List = [] + + def meet_remote_block(phys_path: List[int]): + """Execute meet-in-the-middle CNOTs on phys_path list.""" + L = len(phys_path) - 1 + if L <= 0: + return + if L == 1: + new_ops.append(qml.CNOT(wires=phys_path)) + return + mid = L // 2 + # forward chain + for i in range(mid): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i+1]])) + # backward chain + for i in range(L, mid, -1): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i-1]])) + # central + new_ops.append(qml.CNOT(wires=[phys_path[mid], phys_path[mid+1]])) + # uncompute backward + for i in range(mid+1, L+1): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i-1]])) + # uncompute forward + for i in range(mid-1, -1, -1): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i+1]])) + + # 5) Process each op + for idx, op in enumerate(ops_list): w = list(op.wires) - if len(w) == 2: - q1, q2 = w - p1, p2 = mapping[q1], mapping[q2] - # find shortest physical path - path = nx.shortest_path(G_nx, p1, p2) - # insert SWAPs along path (except final adjacency) - for u, v in zip(path, path[1:]): + # Case A: targeted CNOT routing + if len(w) == 2 and op.name == 'CNOT': + lc, lt = w + pc, pt = mapping[lc], mapping[lt] + phys_path = path[(pc, pt)] + d = len(phys_path) - 1 + if d == 1: + # adjacent + new_ops.append(qml.CNOT(wires=[pc, pt])) + else: + # 5a) select k1, k2 + mid = d // 2 + npc = next_partner[idx][lc] + npt = next_partner[idx][lt] + best_score = float('inf') + best_k1, best_k2 = 0, d + # search split + for k1 in range(mid+1): + # qc at phys_path[k1] + pos1 = phys_path[k1] + for k2 in range(mid, d+1): + if k2 <= k1: + continue + pos2 = phys_path[k2] + score = 0 + if npc is not None: + score += dist[pos1][mapping[npc]] + if npt is not None: + score += dist[pos2][mapping[npt]] + if score < best_score: + best_score = score + best_k1, best_k2 = k1, k2 + # 5b) apply k1 swaps on control side + for i in range(best_k1): + u, v = phys_path[i], phys_path[i+1] + new_ops.append(qml.SWAP(wires=[u, v])) + # update mapping + inv_map = {pos: log for log, pos in mapping.items()} + lu, lv = inv_map[u], inv_map[v] + mapping[lu], mapping[lv] = v, u + # 5c) apply (d-best_k2) swaps on target side + for i in range(d, best_k2, -1): + u, v = phys_path[i], phys_path[i-1] + new_ops.append(qml.SWAP(wires=[u, v])) + inv_map = {pos: log for log, pos in mapping.items()} + lu, lv = inv_map[u], inv_map[v] + mapping[lu], mapping[lv] = v, u + # 5d) remote CNOT on remainder phys_path[best_k1:best_k2+1] + subpath = phys_path[best_k1:best_k2+1] + meet_remote_block(subpath) + # Case B: other 2-qubit gates + elif len(w) == 2: + l0, l1 = w + p0, p1 = mapping[l0], mapping[l1] + phys_path = path[(p0, p1)] + for u, v in zip(phys_path, phys_path[1:]): new_ops.append(qml.SWAP(wires=[u, v])) - # update mapping by swapping the two logical qubits - inv = {phys: log for log, phys in mapping.items()} - l_u, l_v = inv[u], inv[v] - mapping[l_u], mapping[l_v] = v, u - # now the qubits are adjacent: emit the two-qubit gate - new_ops.append(op.map_wires({q1: mapping[q1], q2: mapping[q2]})) - + inv_map = {pos: log for log, pos in mapping.items()} + lu, lv = inv_map[u], inv_map[v] + mapping[lu], mapping[lv] = v, u + new_ops.append(op.map_wires({l0: mapping[l0], l1: mapping[l1]})) + # Case C: single-qubit or others else: - # single- or multi-qubit: just remap wires - wire_map = {q: mapping[q] for q in w} - new_ops.append(op.map_wires(wire_map)) + new_ops.append(op.map_wires({q: mapping[q] for q in w})) - # 4) Remap measurements - new_meas = [ - m.map_wires({q: mapping[q] for q in m.wires}) - for m in tape.measurements - ] + # 6) Remap measurements + new_meas = [m.map_wires({q: mapping[q] for q in m.wires}) for m in tape.measurements] - return [QuantumScript(new_ops, new_meas)], lambda results: results[0] \ No newline at end of file + return [QuantumScript(new_ops, new_meas)], lambda results: results[0] From a57e70d59fb6a54d9e3d5470d3eae62a90763801 Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Mon, 5 May 2025 19:01:50 -0400 Subject: [PATCH 3/6] Tests and documentation --- pennylane/transforms/optimization/__init__.py | 2 +- .../transforms/optimization/qubit_mapping.py | 354 ++++++++++++------ .../test_optimization/test_qubit_mapping.py | 247 ++++++++++++ 3 files changed, 483 insertions(+), 120 deletions(-) create mode 100644 tests/transforms/test_optimization/test_qubit_mapping.py diff --git a/pennylane/transforms/optimization/__init__.py b/pennylane/transforms/optimization/__init__.py index 47f655e0c24..741447d6281 100644 --- a/pennylane/transforms/optimization/__init__.py +++ b/pennylane/transforms/optimization/__init__.py @@ -23,4 +23,4 @@ from .single_qubit_fusion import single_qubit_fusion from .undo_swaps import undo_swaps from .pattern_matching import pattern_matching, pattern_matching_optimization -from .qubit_mapping import qubit_mapping \ No newline at end of file +from .qubit_mapping import qubit_mapping diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py index e9bd1bd2897..448cab377b5 100644 --- a/pennylane/transforms/optimization/qubit_mapping.py +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -1,172 +1,288 @@ +# Copyright 2025 Xanadu Quantum Technologies Inc. +# 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 +# +# http://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. + +"""Transform for hybrid CNOT routing and logical-to-physical qubit mapping on arbitrary connectivity graphs.""" + +import networkx as nx + import pennylane as qml -from pennylane.transforms import transform from pennylane.tape import QuantumScript -import networkx as nx -from typing import Dict, List, Optional, Tuple +from pennylane.transforms import transform + @transform -def qubit_mapping( - tape, - graph: Dict[int, List[int]], - init_mapping: Optional[Dict[int, int]] = None -) -> Tuple[List[QuantumScript], callable]: - """ - Hybrid routing transform: - - Implements CNOT routing by selectively swapping control and target - qubits toward optimal split points on the shortest path, then using a - remote meet-in-the-middle CNOT block on the remaining subpath. - - Steps: - 1) Build the physical connectivity graph. - 2) Initialize logical→physical mapping (identity by default). - 3) Precompute all-pairs shortest paths and distances. - 4) Determine the next logical partner for each qubit at each CNOT index. - 5) For each operation: - - If non-adjacent CNOT: - a) Find shortest physical path of length d+1. - b) Let mid = floor(d/2). Search k1 in [0..mid], k2 in [mid..d], k2>k1 - minimizing: dist(path[k1], next_partner_of_control) + - dist(path[k2], next_partner_of_target). - c) Apply k1 SWAPs on the control end (path[0..k1]) and k2..d SWAPs - on the target end (path[d..k2]), updating mapping. - d) Run the meet-in-the-middle CNOT block on the subpath - path[k1..k2] (inclusive). - - Else if other 2-qubit gate: SWAP-based routing + native gate. - - Else: rename single-qubit ops by mapping. - 6) Remap measurements. +def qubit_mapping(tape, graph, init_mapping=None): + """Qubit mapping transform. + + Implements a qubit‐mapping scheme that connects nonadjacent logical qubits using SWAP + operations and long-range CNOTs. Each qubit’s placement is dynamically chosen based on + the location of its next scheduled gate. + + Supports cases with more physical qubits than logical wires by mapping + extra physical qubits arbitrarily if no init_mapping is provided. + + Args: + tape (QNode or QuantumTape or Callable): The input quantum circuit to transform. + graph (dict): Adjacency list describing the connectivity of + the physical qubits. + init_mapping (dict or None): Optional initial mapping from logical + wires to physical qubits. If None, a default mapping is chosen. + + Returns: + qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], function]: The transformed circuit as described in :func:`qml.transform `. + + **Example** + + .. code-block:: python + + dev = qml.device('default.qubit') + + graph = {"a": ["b"], "b": ["a", "c"], "c": ["b"]} + initial_map = {0: "a", 1: "b", 2: "c"} + + @partial(qml.transforms.qubit_mapping, graph = graph) + @qml.qnode(dev) + def circuit(): + qml.Hadamard(0) + qml.CNOT([0,2]) + return qml.expval(qml.Z(2)) + + >>> print(qml.draw(circuit)()) + a: ──H────╭●─┤ + b: ─╭SWAP─╰X─┤ + c: ─╰SWAP────┤ + """ - # 1) Build physical graph - phys = nx.Graph() + # Build physical graph + phys_graph = nx.Graph() for q, nbrs in graph.items(): - phys.add_edges_from((q, nbr) for nbr in nbrs) + phys_graph.add_edges_from((q, nbr) for nbr in nbrs) + + # Initialize mapping + logical_qubits = tape.wires + num_logical = len(logical_qubits) + phys_qubits = list(graph.keys()) + num_phys = len(phys_qubits) - # 2) Initialize mapping if init_mapping is None: - logicals = {w for op in tape.operations for w in op.wires} - mapping = {q: q for q in logicals} + if all(w in phys_qubits for w in logical_qubits): + mapping = {w: w for w in logical_qubits} + elif num_logical <= num_phys: + mapping = {logical_qubits[i]: phys_qubits[i] for i in range(num_logical)} + else: + raise ValueError( + f"Insufficient physical qubits: {num_phys} < {num_logical} logical wires." + ) else: mapping = init_mapping.copy() - # 3) Precompute shortest paths and distances - spaths = dict(nx.all_pairs_shortest_path(phys)) - dist = {u: {v: len(p)-1 for v,p in targets.items()} for u,targets in spaths.items()} - path = {(u,v): p for u,targets in spaths.items() for v,p in targets.items()} + # Precompute shortest paths and distances in the graph + shortest_paths = dict(nx.all_pairs_shortest_path(phys_graph)) + dist = {u: {v: len(p) - 1 for v, p in targets.items()} for u, targets in shortest_paths.items()} + path = {(u, v): p for u, targets in shortest_paths.items() for v, p in targets.items()} + + + # Create a dependency graph of operators (``next_partner``) + + future = {w: [] for w in logical_qubits} + # future[] --> List[(, )] + # This means: the operators where is involved are + # the th operators, that whose second action qubit is - # 4) Precompute next logical partner per CNOT ops_list = list(tape.operations) - future: Dict[int, List[Tuple[int,int]]] = {q: [] for op in ops_list for q in op.wires} for idx, op in enumerate(ops_list): - if op.name == 'CNOT': - c, t = op.wires - future[c].append((idx, t)) - future[t].append((idx, c)) - next_partner: List[Dict[int, Optional[int]]] = [{} for _ in ops_list] + if len(op.wires) == 2: + wire0, wire1 = op.wires + future[wire0].append((idx, wire1)) + future[wire1].append((idx, wire0)) + + next_partner = [{} for _ in ops_list] + # next_partner[][] --> + # This means: After apply the th-operator, the next operation + # that need to be linked with is located in for idx in range(len(ops_list)): - for q in mapping: - # find the smallest j>idx where q participates + for q in logical_qubits: part = None for j, p in future[q]: if j > idx: part = p break next_partner[idx][q] = part + new_ops = [] - new_ops: List = [] - - def meet_remote_block(phys_path: List[int]): - """Execute meet-in-the-middle CNOTs on phys_path list.""" + def longe_range_cnot(phys_path): L = len(phys_path) - 1 if L <= 0: return if L == 1: new_ops.append(qml.CNOT(wires=phys_path)) return + + # We implement the long range CNOT (Fig 4c [https://arxiv.org/pdf/2305.18128]) mid = L // 2 - # forward chain for i in range(mid): - new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i+1]])) - # backward chain + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) for i in range(L, mid, -1): - new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i-1]])) - # central - new_ops.append(qml.CNOT(wires=[phys_path[mid], phys_path[mid+1]])) - # uncompute backward - for i in range(mid+1, L+1): - new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i-1]])) - # uncompute forward - for i in range(mid-1, -1, -1): - new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i+1]])) - - # 5) Process each op + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i - 1]])) + new_ops.append(qml.CNOT(wires=[phys_path[mid], phys_path[mid + 1]])) + for i in range(mid + 1, L + 1): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i - 1]])) + for i in range(mid - 1, -1, -1): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) + + # Process operations for idx, op in enumerate(ops_list): w = list(op.wires) - # Case A: targeted CNOT routing - if len(w) == 2 and op.name == 'CNOT': - lc, lt = w - pc, pt = mapping[lc], mapping[lt] - phys_path = path[(pc, pt)] + # A: CNOT routing + if len(w) == 2 and op.name == "CNOT": + logical_control, logical_target = w + phys_control, phys_target = mapping[logical_control], mapping[logical_target] + phys_path = path[(phys_control, phys_target)] d = len(phys_path) - 1 if d == 1: - # adjacent - new_ops.append(qml.CNOT(wires=[pc, pt])) + new_ops.append(qml.CNOT(wires=[phys_control, phys_target])) else: - # 5a) select k1, k2 mid = d // 2 - npc = next_partner[idx][lc] - npt = next_partner[idx][lt] - best_score = float('inf') + partner_control = next_partner[idx][logical_control] + partner_target = next_partner[idx][logical_target] + best_score = float("inf") best_k1, best_k2 = 0, d - # search split - for k1 in range(mid+1): - # qc at phys_path[k1] + for k1 in range(mid + 1): pos1 = phys_path[k1] - for k2 in range(mid, d+1): + for k2 in range(mid, d + 1): if k2 <= k1: continue pos2 = phys_path[k2] score = 0 - if npc is not None: - score += dist[pos1][mapping[npc]] - if npt is not None: - score += dist[pos2][mapping[npt]] - if score < best_score: + + # The score is the distance from the current positions to the next connection. + if partner_control is not None: + score += dist[pos1][mapping[partner_control]] + if partner_target is not None: + score += dist[pos2][mapping[partner_target]] + if score < best_score or ( + score == best_score and (k2 - k1) < (best_k2 - best_k1) + ): best_score = score best_k1, best_k2 = k1, k2 - # 5b) apply k1 swaps on control side + + # swaps to the best positions (control qubit) for i in range(best_k1): - u, v = phys_path[i], phys_path[i+1] - new_ops.append(qml.SWAP(wires=[u, v])) - # update mapping - inv_map = {pos: log for log, pos in mapping.items()} - lu, lv = inv_map[u], inv_map[v] - mapping[lu], mapping[lv] = v, u - # 5c) apply (d-best_k2) swaps on target side + phys_u, phys_v = phys_path[i], phys_path[i + 1] + new_ops.append(qml.SWAP(wires=[phys_u, phys_v])) + inv_map = {pos: logical for logical, pos in mapping.items()} + logical_u = inv_map.get(phys_u) + logical_v = inv_map.get(phys_v) + if logical_u is not None: + mapping[logical_u] = phys_v + if logical_v is not None: + mapping[logical_v] = phys_u + + # swaps to the best positions (target qubit) for i in range(d, best_k2, -1): - u, v = phys_path[i], phys_path[i-1] - new_ops.append(qml.SWAP(wires=[u, v])) - inv_map = {pos: log for log, pos in mapping.items()} - lu, lv = inv_map[u], inv_map[v] - mapping[lu], mapping[lv] = v, u - # 5d) remote CNOT on remainder phys_path[best_k1:best_k2+1] - subpath = phys_path[best_k1:best_k2+1] - meet_remote_block(subpath) - # Case B: other 2-qubit gates + phys_u, phys_v = phys_path[i], phys_path[i - 1] + new_ops.append(qml.SWAP(wires=[phys_u, phys_v])) + inv_map = {pos: logical for logical, pos in mapping.items()} + logical_u = inv_map.get(phys_u) + logical_v = inv_map.get(phys_v) + if logical_u is not None: + mapping[logical_u] = phys_v + if logical_v is not None: + mapping[logical_v] = phys_u + + # long range cnot to connect the operations + subpath = phys_path[best_k1 : best_k2 + 1] + longe_range_cnot(subpath) + + # B: other 2-qubit gates (long range handled via optimized adjacent swaps) elif len(w) == 2: - l0, l1 = w - p0, p1 = mapping[l0], mapping[l1] - phys_path = path[(p0, p1)] - for u, v in zip(phys_path, phys_path[1:]): - new_ops.append(qml.SWAP(wires=[u, v])) - inv_map = {pos: log for log, pos in mapping.items()} - lu, lv = inv_map[u], inv_map[v] - mapping[lu], mapping[lv] = v, u - new_ops.append(op.map_wires({l0: mapping[l0], l1: mapping[l1]})) - # Case C: single-qubit or others + logical_0, logical_1 = w + phys_0, phys_1 = mapping[logical_0], mapping[logical_1] + phys_path = path[(phys_0, phys_1)] + d = len(phys_path) - 1 + + if d == 1: + # Adjacent already: apply gate directly + new_ops.append(op.map_wires({logical_0: phys_0, logical_1: phys_1})) + else: + # Compute next partners and find best meeting edge + npc = next_partner[idx][logical_0] + npt = next_partner[idx][logical_1] + + best_score = float("inf") + best_edge = 0 + for b in range(d): + u, v = phys_path[b], phys_path[b + 1] + score = 0 + if npc is not None: + score += dist[u][mapping[npc]] + if npt is not None: + score += dist[v][mapping[npt]] + if score < best_score: + best_score = score + best_edge = b + + # Determine the target adjacent positions + left_target = phys_path[best_edge] + right_target = phys_path[best_edge + 1] + + # Move each qubit by adjacent swaps until they occupy the target edge + while (mapping[logical_0], mapping[logical_1]) != (left_target, right_target): + current_0 = mapping[logical_0] + current_1 = mapping[logical_1] + + if current_0 != left_target: + # Swap logical_0 one step toward left_target + idx0 = phys_path.index(current_0) + next_pos = phys_path[idx0 + 1] + new_ops.append(qml.SWAP(wires=[current_0, next_pos])) + inv_map = {pos: log for log, pos in mapping.items()} + lu = inv_map.get(current_0) + lv = inv_map.get(next_pos) + if lu is not None: + mapping[lu] = next_pos + if lv is not None: + mapping[lv] = current_0 + + else: + # Swap logical_1 one step toward right_target + idx1 = phys_path.index(current_1) + next_pos = phys_path[idx1 - 1] + new_ops.append(qml.SWAP(wires=[current_1, next_pos])) + inv_map = {pos: log for log, pos in mapping.items()} + lu = inv_map.get(current_1) + lv = inv_map.get(next_pos) + if lu is not None: + mapping[lu] = next_pos + if lv is not None: + mapping[lv] = current_1 + + # 4. Now adjacent at optimal edge: apply the two-qubit gate + new_ops.append( + op.map_wires( + { + logical_0: mapping[logical_0], + logical_1: mapping[logical_1], + } + ) + ) + + elif len(w) > 2: + raise ValueError(f"All operations should act in less than 3 wires.") + + # C: single-qubit else: new_ops.append(op.map_wires({q: mapping[q] for q in w})) - # 6) Remap measurements new_meas = [m.map_wires({q: mapping[q] for q in m.wires}) for m in tape.measurements] - return [QuantumScript(new_ops, new_meas)], lambda results: results[0] diff --git a/tests/transforms/test_optimization/test_qubit_mapping.py b/tests/transforms/test_optimization/test_qubit_mapping.py new file mode 100644 index 00000000000..929f166d541 --- /dev/null +++ b/tests/transforms/test_optimization/test_qubit_mapping.py @@ -0,0 +1,247 @@ +# Copyright 2025 Xanadu Quantum Technologies Inc. + +# 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 + +# http://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. +""" +Unit tests for the optimization transform ``qubit_mapping``. +""" + +import pytest + +import pennylane as qml +from pennylane import numpy as np +from pennylane.transforms.optimization import qubit_mapping + + +class TestQubitMapping: + """Test that circuit is mapped to a correct architecture.""" + + def test_all_conected(self): + """Test that an all-conected architecture does not modify the circuit.""" + + def qfunc(): + qml.CNOT(wires=[0, 1]) + qml.CNOT(wires=[0, 2]) + qml.CZ(wires=[0, 3]) + qml.CZ(wires=[0, 1]) + qml.SWAP(wires=[0, 2]) + + graph = { + "a": ["b", "c", "d"], + "b": ["a", "c", "d"], + "c": ["a", "b", "d"], + "d": ["a", "b", "c"], + } + + initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + transformed_qfunc = qubit_mapping(qfunc, graph, initial_map) + + new_tape = qml.tape.make_qscript(transformed_qfunc)() + assert len(new_tape.operations) == 5 + + @pytest.mark.parametrize( + ("ops", "meas"), + [ + ( + [ + qml.RY(2, wires=0), + qml.CNOT(wires=[0, 1]), + qml.CNOT(wires=[0, 2]), + qml.RX(3, wires=1), + qml.CZ(wires=[0, 3]), + qml.CZ(wires=[0, 1]), + qml.RY(4, wires=2), + qml.SWAP(wires=[0, 2]), + ], + [qml.expval(qml.X(0))], + ), + ( + [ + qml.Hadamard(0), + qml.Hadamard(2), + qml.CRY(2, wires=[0, 3]), + qml.CNOT(wires=[2, 0]), + qml.SWAP(wires=[0, 2]), + qml.RX(3, wires=3), + qml.CZ(wires=[0, 3]), + qml.CZ(wires=[0, 2]), + qml.RY(4, wires=2), + qml.SWAP(wires=[0, 2]), + ], + [qml.expval(qml.Y(3))], + ), + ], + ) + def test_correctness_solution(self, ops, meas): + """Test that the output is not modified by the transform""" + + qs = qml.tape.QuantumScript( + ops, + meas, + ) + + graph = {"a": ["b", "d"], "b": ["a", "c"], "c": ["b"], "d": ["a"]} + + initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + transformed_qs = qubit_mapping(qs, graph, initial_map) + + dev = qml.device("default.qubit") + + program, _ = dev.preprocess() + tape = program([qs]) + initial_output = dev.execute(tape[0]) + + program, _ = dev.preprocess() + tape = program([transformed_qs[0][0]]) + new_output = dev.execute(tape[0]) + + assert np.isclose(initial_output, new_output) + + @pytest.mark.parametrize( + ("ops", "meas"), + [ + ( + [ + qml.RY(2, wires=0), + qml.CNOT(wires=[0, 1]), + qml.CNOT(wires=[0, 2]), + qml.RX(3, wires=1), + qml.CZ(wires=[0, 3]), + qml.CZ(wires=[0, 1]), + qml.RY(4, wires=2), + qml.SWAP(wires=[0, 2]), + ], + [qml.expval(qml.X(0))], + ), + ( + [ + qml.Hadamard(0), + qml.Hadamard(2), + qml.CRY(2, wires=[0, 3]), + qml.CNOT(wires=[2, 0]), + qml.SWAP(wires=[0, 2]), + qml.RX(3, wires=3), + qml.CZ(wires=[0, 3]), + qml.CZ(wires=[0, 2]), + qml.RY(4, wires=2), + qml.SWAP(wires=[0, 2]), + ], + [qml.expval(qml.Y(3))], + ), + ], + ) + def test_correctness_connectivity(self, ops, meas): + """Test that after routing only allowed physical connections appear.""" + + qs = qml.tape.QuantumScript( + ops, + meas, + ) + graph = { + "a": ["b", "d"], + "b": ["a", "c"], + "c": ["b"], + "d": ["a"], + } + + initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + + transformed_qs = qubit_mapping(qs, graph, initial_map) + new_tape = transformed_qs[0][0] + + for op in new_tape.operations: + wires = op.wires + if len(wires) == 2: + w0, w1 = wires + assert w1 in graph.get(w0, []) or w0 in graph.get(w1, []) + + def test_wires_is_mapped(self): + """Test that the output tape has been labelled with the new qubits""" + + graph = { + "physical 0": ["physical 1", "physical 2"], + "physical 1": ["physical 0"], + "physical 2": ["physical 0"], + } + + initial_map = {f"logical {i}": f"physical {i}" for i in range(3)} + + qs = qml.tape.QuantumScript( + [qml.Hadamard("logical 0"), qml.CZ(["logical 1", "logical 2"])], + [qml.expval(qml.Z("logical 0"))], + ) + + transformed_qs = qubit_mapping(qs, graph, initial_map) + + for op in transformed_qs[0][0].operations: + for wire in op.wires: + assert "physical" in wire + + def test_more_physical_wires(self): + """Test that the target architecture could have more wires than the initial circuit""" + + graph = { + "physical 0": ["physical 1", "physical 2"], + "physical 1": ["physical 0"], + "physical 2": ["physical 0"], + } + + initial_map = {f"logical {i}": f"physical {i}" for i in range(1, 3)} + + qs = qml.tape.QuantumScript( + [qml.Hadamard("logical 1"), qml.CZ(["logical 1", "logical 2"])], + [qml.expval(qml.Z("logical 2"))], + ) + + transformed_qs = qubit_mapping(qs, graph, initial_map) + + for op in transformed_qs[0][0].operations: + for wire in op.wires: + assert "physical" in wire + + def test_error_too_many_qubits(self): + """Test that an error appears if there is an operator that acts in more than 2 qubits""" + + def qfunc(): + qml.GroverOperator(wires=[0, 1, 2]) + qml.CNOT(wires=[0, 2]) + qml.CZ(wires=[0, 3]) + + graph = { + "a": ["b", "c", "d"], + "b": ["a", "c", "d"], + "c": ["a", "b", "d"], + "d": ["a", "b", "c"], + } + + initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + + with pytest.raises(ValueError, match="All operations should act in less than 3 wires."): + transformed_qfunc = qubit_mapping(qfunc, graph, initial_map) + _ = qml.tape.make_qscript(transformed_qfunc)() + + def test_error_not_enough_qubits(self): + """Test that an error appears if the number of logical qubits is not greater than the physical qubits""" + + def qfunc(): + qml.CNOT(wires=[2, 1]) + qml.CZ(wires=[0, 3]) + + graph = { + "a": ["b", "c"], + "b": ["a", "c"], + "c": ["a", "b"], + } + + with pytest.raises(ValueError, match="Insufficient physical qubits"): + transformed_qfunc = qubit_mapping(qfunc, graph) + _ = qml.tape.make_qscript(transformed_qfunc)() From e2e8c56b04e7b7540f082776d5ee707686adce28 Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Tue, 6 May 2025 21:21:30 -0400 Subject: [PATCH 4/6] Update qubit_mapping.py --- .../transforms/optimization/qubit_mapping.py | 294 +++++++----------- 1 file changed, 107 insertions(+), 187 deletions(-) diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py index 448cab377b5..5da845e7f57 100644 --- a/pennylane/transforms/optimization/qubit_mapping.py +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -11,25 +11,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transform for hybrid CNOT routing and logical-to-physical qubit mapping on arbitrary connectivity graphs.""" - import networkx as nx - import pennylane as qml from pennylane.tape import QuantumScript from pennylane.transforms import transform @transform -def qubit_mapping(tape, graph, init_mapping=None): - """Qubit mapping transform. - - Implements a qubit‐mapping scheme that connects nonadjacent logical qubits using SWAP - operations and long-range CNOTs. Each qubit’s placement is dynamically chosen based on - the location of its next scheduled gate. - - Supports cases with more physical qubits than logical wires by mapping - extra physical qubits arbitrarily if no init_mapping is provided. +def qubit_mapping(tape, graph, init_mapping=None, window_size=10): + """Qubit mapping transform with sliding window for dependency lookahead. Args: tape (QNode or QuantumTape or Callable): The input quantum circuit to transform. @@ -37,6 +27,7 @@ def qubit_mapping(tape, graph, init_mapping=None): the physical qubits. init_mapping (dict or None): Optional initial mapping from logical wires to physical qubits. If None, a default mapping is chosen. + window_size (int): Number of upcoming operations to inspect for dependencies. Returns: qnode (QNode) or quantum function (Callable) or tuple[List[QuantumTape], function]: The transformed circuit as described in :func:`qml.transform `. @@ -48,7 +39,6 @@ def qubit_mapping(tape, graph, init_mapping=None): dev = qml.device('default.qubit') graph = {"a": ["b"], "b": ["a", "c"], "c": ["b"]} - initial_map = {0: "a", 1: "b", 2: "c"} @partial(qml.transforms.qubit_mapping, graph = graph) @qml.qnode(dev) @@ -61,9 +51,8 @@ def circuit(): a: ──H────╭●─┤ b: ─╭SWAP─╰X─┤ c: ─╰SWAP────┤ - """ - # Build physical graph + # Build physical connectivity graph phys_graph = nx.Graph() for q, nbrs in graph.items(): phys_graph.add_edges_from((q, nbr) for nbr in nbrs) @@ -73,51 +62,45 @@ def circuit(): num_logical = len(logical_qubits) phys_qubits = list(graph.keys()) num_phys = len(phys_qubits) - if init_mapping is None: if all(w in phys_qubits for w in logical_qubits): mapping = {w: w for w in logical_qubits} elif num_logical <= num_phys: mapping = {logical_qubits[i]: phys_qubits[i] for i in range(num_logical)} else: - raise ValueError( - f"Insufficient physical qubits: {num_phys} < {num_logical} logical wires." - ) + raise ValueError(f"Insufficient physical qubits: {num_phys} < {num_logical}.") else: mapping = init_mapping.copy() - # Precompute shortest paths and distances in the graph - shortest_paths = dict(nx.all_pairs_shortest_path(phys_graph)) - dist = {u: {v: len(p) - 1 for v, p in targets.items()} for u, targets in shortest_paths.items()} - path = {(u, v): p for u, targets in shortest_paths.items() for v, p in targets.items()} - + # Lazy path & distance caches + path_cache, dist_cache = {}, {} - # Create a dependency graph of operators (``next_partner``) + def get_path(u, v): + if (u, v) not in path_cache: + p = nx.shortest_path(phys_graph, source=u, target=v) + path_cache[(u, v)] = p + path_cache[(v, u)] = list(reversed(p)) + dist_cache[(u, v)] = len(p) - 1 + dist_cache[(v, u)] = len(p) - 1 + return path_cache[(u, v)] - future = {w: [] for w in logical_qubits} - # future[] --> List[(, )] - # This means: the operators where is involved are - # the th operators, that whose second action qubit is + def get_dist(u, v): + if (u, v) not in dist_cache: + get_path(u, v) + return dist_cache[(u, v)] ops_list = list(tape.operations) - for idx, op in enumerate(ops_list): - if len(op.wires) == 2: - wire0, wire1 = op.wires - future[wire0].append((idx, wire1)) - future[wire1].append((idx, wire0)) - next_partner = [{} for _ in ops_list] - # next_partner[][] --> - # This means: After apply the th-operator, the next operation - # that need to be linked with is located in - for idx in range(len(ops_list)): - for q in logical_qubits: - part = None - for j, p in future[q]: - if j > idx: - part = p - break - next_partner[idx][q] = part + # Sliding-window lookup for next 2-qubit partner + def find_next_partner(idx, qubit): + end = min(idx + 1 + window_size, len(ops_list)) + for j in range(idx + 1, end): + op_j = ops_list[j] + if len(op_j.wires) == 2 and qubit in op_j.wires: + w0, w1 = op_j.wires + return w1 if w0 == qubit else w0 + return None + new_ops = [] def longe_range_cnot(phys_path): @@ -127,8 +110,6 @@ def longe_range_cnot(phys_path): if L == 1: new_ops.append(qml.CNOT(wires=phys_path)) return - - # We implement the long range CNOT (Fig 4c [https://arxiv.org/pdf/2305.18128]) mid = L // 2 for i in range(mid): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) @@ -140,149 +121,88 @@ def longe_range_cnot(phys_path): for i in range(mid - 1, -1, -1): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) - # Process operations + # Process each operation for idx, op in enumerate(ops_list): - w = list(op.wires) - # A: CNOT routing - if len(w) == 2 and op.name == "CNOT": - logical_control, logical_target = w - phys_control, phys_target = mapping[logical_control], mapping[logical_target] - phys_path = path[(phys_control, phys_target)] - d = len(phys_path) - 1 - if d == 1: - new_ops.append(qml.CNOT(wires=[phys_control, phys_target])) - else: - mid = d // 2 - partner_control = next_partner[idx][logical_control] - partner_target = next_partner[idx][logical_target] - best_score = float("inf") - best_k1, best_k2 = 0, d - for k1 in range(mid + 1): - pos1 = phys_path[k1] - for k2 in range(mid, d + 1): - if k2 <= k1: - continue - pos2 = phys_path[k2] - score = 0 - - # The score is the distance from the current positions to the next connection. - if partner_control is not None: - score += dist[pos1][mapping[partner_control]] - if partner_target is not None: - score += dist[pos2][mapping[partner_target]] - if score < best_score or ( - score == best_score and (k2 - k1) < (best_k2 - best_k1) - ): - best_score = score - best_k1, best_k2 = k1, k2 - - # swaps to the best positions (control qubit) - for i in range(best_k1): - phys_u, phys_v = phys_path[i], phys_path[i + 1] - new_ops.append(qml.SWAP(wires=[phys_u, phys_v])) - inv_map = {pos: logical for logical, pos in mapping.items()} - logical_u = inv_map.get(phys_u) - logical_v = inv_map.get(phys_v) - if logical_u is not None: - mapping[logical_u] = phys_v - if logical_v is not None: - mapping[logical_v] = phys_u - - # swaps to the best positions (target qubit) - for i in range(d, best_k2, -1): - phys_u, phys_v = phys_path[i], phys_path[i - 1] - new_ops.append(qml.SWAP(wires=[phys_u, phys_v])) - inv_map = {pos: logical for logical, pos in mapping.items()} - logical_u = inv_map.get(phys_u) - logical_v = inv_map.get(phys_v) - if logical_u is not None: - mapping[logical_u] = phys_v - if logical_v is not None: - mapping[logical_v] = phys_u - - # long range cnot to connect the operations - subpath = phys_path[best_k1 : best_k2 + 1] - longe_range_cnot(subpath) - - # B: other 2-qubit gates (long range handled via optimized adjacent swaps) - elif len(w) == 2: - logical_0, logical_1 = w - phys_0, phys_1 = mapping[logical_0], mapping[logical_1] - phys_path = path[(phys_0, phys_1)] + wires = list(op.wires) + if len(wires) == 2: + l0, l1 = wires + p0, p1 = mapping[l0], mapping[l1] + phys_path = get_path(p0, p1) d = len(phys_path) - 1 - - if d == 1: - # Adjacent already: apply gate directly - new_ops.append(op.map_wires({logical_0: phys_0, logical_1: phys_1})) + # Handle CNOT specifically + if op.name == "CNOT": + if d == 1: + new_ops.append(qml.CNOT(wires=[p0, p1])) + else: + mid = d // 2 + pc = find_next_partner(idx, l0) + pt = find_next_partner(idx, l1) + # independent optimal positions + best_k1 = min( + range(mid + 1), + key=lambda k: get_dist(phys_path[k], mapping[pc]) if pc else 0, + ) + best_k2 = min( + range(mid, d + 1), + key=lambda k: get_dist(phys_path[k], mapping[pt]) if pt else 0, + ) + # swaps control + for i in range(best_k1): + u, v = phys_path[i], phys_path[i + 1] + new_ops.append(qml.SWAP(wires=[u, v])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(u): + mapping[inv[u]] = v + if inv.get(v): + mapping[inv[v]] = u + # swaps target + for i in range(d, best_k2, -1): + u, v = phys_path[i], phys_path[i - 1] + new_ops.append(qml.SWAP(wires=[u, v])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(u): + mapping[inv[u]] = v + if inv.get(v): + mapping[inv[v]] = u + longe_range_cnot(phys_path[best_k1 : best_k2 + 1]) else: - # Compute next partners and find best meeting edge - npc = next_partner[idx][logical_0] - npt = next_partner[idx][logical_1] - - best_score = float("inf") - best_edge = 0 - for b in range(d): - u, v = phys_path[b], phys_path[b + 1] - score = 0 - if npc is not None: - score += dist[u][mapping[npc]] - if npt is not None: - score += dist[v][mapping[npt]] - if score < best_score: - best_score = score - best_edge = b - - # Determine the target adjacent positions - left_target = phys_path[best_edge] - right_target = phys_path[best_edge + 1] - - # Move each qubit by adjacent swaps until they occupy the target edge - while (mapping[logical_0], mapping[logical_1]) != (left_target, right_target): - current_0 = mapping[logical_0] - current_1 = mapping[logical_1] - - if current_0 != left_target: - # Swap logical_0 one step toward left_target - idx0 = phys_path.index(current_0) - next_pos = phys_path[idx0 + 1] - new_ops.append(qml.SWAP(wires=[current_0, next_pos])) - inv_map = {pos: log for log, pos in mapping.items()} - lu = inv_map.get(current_0) - lv = inv_map.get(next_pos) - if lu is not None: - mapping[lu] = next_pos - if lv is not None: - mapping[lv] = current_0 - - else: - # Swap logical_1 one step toward right_target - idx1 = phys_path.index(current_1) - next_pos = phys_path[idx1 - 1] - new_ops.append(qml.SWAP(wires=[current_1, next_pos])) - inv_map = {pos: log for log, pos in mapping.items()} - lu = inv_map.get(current_1) - lv = inv_map.get(next_pos) - if lu is not None: - mapping[lu] = next_pos - if lv is not None: - mapping[lv] = current_1 - - # 4. Now adjacent at optimal edge: apply the two-qubit gate - new_ops.append( - op.map_wires( - { - logical_0: mapping[logical_0], - logical_1: mapping[logical_1], - } + # generic 2-qubit gate via adjacent swaps + if d == 1: + new_ops.append(op.map_wires({l0: p0, l1: p1})) + else: + npc = find_next_partner(idx, l0) + npt = find_next_partner(idx, l1) + best_edge = min( + range(d), + key=lambda b: (get_dist(phys_path[b], mapping[npc]) if npc else 0) + + (get_dist(phys_path[b + 1], mapping[npt]) if npt else 0), ) - ) - - elif len(w) > 2: - raise ValueError(f"All operations should act in less than 3 wires.") - - # C: single-qubit + left, right = phys_path[best_edge], phys_path[best_edge + 1] + while (mapping[l0], mapping[l1]) != (left, right): + if mapping[l0] != left: + cur = mapping[l0] + nxt = phys_path[phys_path.index(cur) + 1] + new_ops.append(qml.SWAP(wires=[cur, nxt])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(cur): + mapping[inv[cur]] = nxt + if inv.get(nxt): + mapping[inv[nxt]] = cur + else: + cur = mapping[l1] + nxt = phys_path[phys_path.index(cur) - 1] + new_ops.append(qml.SWAP(wires=[cur, nxt])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(cur): + mapping[inv[cur]] = nxt + if inv.get(nxt): + mapping[inv[nxt]] = cur + new_ops.append(op.map_wires({l0: mapping[l0], l1: mapping[l1]})) + elif len(wires) > 2: + raise ValueError("All operations should act in less than 3 wires.") else: - new_ops.append(op.map_wires({q: mapping[q] for q in w})) + new_ops.append(op.map_wires({q: mapping[q] for q in wires})) + # Remap measurements new_meas = [m.map_wires({q: mapping[q] for q in m.wires}) for m in tape.measurements] - return [QuantumScript(new_ops, new_meas)], lambda results: results[0] + return [QuantumScript(new_ops, new_meas)], lambda res: res[0] From fe45ea82834469476957ba61fdb67e07f6f9a2eb Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Wed, 7 May 2025 16:12:57 -0400 Subject: [PATCH 5/6] previous version --- .../transforms/optimization/qubit_mapping.py | 238 ++++++++++-------- 1 file changed, 135 insertions(+), 103 deletions(-) diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py index 5da845e7f57..26a863f6220 100644 --- a/pennylane/transforms/optimization/qubit_mapping.py +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -11,14 +11,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Transform for mapping a quantum circuit into a given architecture.""" +# pylint: disable=too-many-branches + +from functools import lru_cache + import networkx as nx + import pennylane as qml from pennylane.tape import QuantumScript from pennylane.transforms import transform @transform -def qubit_mapping(tape, graph, init_mapping=None, window_size=10): +def qubit_mapping(tape, graph, init_mapping=None): """Qubit mapping transform with sliding window for dependency lookahead. Args: @@ -57,53 +63,56 @@ def circuit(): for q, nbrs in graph.items(): phys_graph.add_edges_from((q, nbr) for nbr in nbrs) - # Initialize mapping + # On-demand cached shortest path and distance + @lru_cache(maxsize=None) + def get_path(u, v): + return nx.shortest_path(phys_graph, u, v) + + @lru_cache(maxsize=None) + def get_dist(u, v): + return len(get_path(u, v)) - 1 + + # Initialize logical-to-physical mapping logical_qubits = tape.wires - num_logical = len(logical_qubits) phys_qubits = list(graph.keys()) + num_logical = len(logical_qubits) num_phys = len(phys_qubits) + if init_mapping is None: if all(w in phys_qubits for w in logical_qubits): mapping = {w: w for w in logical_qubits} elif num_logical <= num_phys: mapping = {logical_qubits[i]: phys_qubits[i] for i in range(num_logical)} else: - raise ValueError(f"Insufficient physical qubits: {num_phys} < {num_logical}.") + raise ValueError( + f"Insufficient physical qubits: {num_phys} < {num_logical} logical wires." + ) else: mapping = init_mapping.copy() - # Lazy path & distance caches - path_cache, dist_cache = {}, {} - - def get_path(u, v): - if (u, v) not in path_cache: - p = nx.shortest_path(phys_graph, source=u, target=v) - path_cache[(u, v)] = p - path_cache[(v, u)] = list(reversed(p)) - dist_cache[(u, v)] = len(p) - 1 - dist_cache[(v, u)] = len(p) - 1 - return path_cache[(u, v)] - - def get_dist(u, v): - if (u, v) not in dist_cache: - get_path(u, v) - return dist_cache[(u, v)] - + # Precompute future two-qubit dependencies + future = {w: [] for w in logical_qubits} ops_list = list(tape.operations) - - # Sliding-window lookup for next 2-qubit partner - def find_next_partner(idx, qubit): - end = min(idx + 1 + window_size, len(ops_list)) - for j in range(idx + 1, end): - op_j = ops_list[j] - if len(op_j.wires) == 2 and qubit in op_j.wires: - w0, w1 = op_j.wires - return w1 if w0 == qubit else w0 - return None + for idx, op in enumerate(ops_list): + if len(op.wires) == 2: + w0, w1 = op.wires + future[w0].append((idx, w1)) + future[w1].append((idx, w0)) + + next_partner = [{} for _ in ops_list] + for idx in range(len(ops_list)): + for q in logical_qubits: + part = None + for j, p in future[q]: + if j > idx: + part = p + break + next_partner[idx][q] = part new_ops = [] - def longe_range_cnot(phys_path): + # Long-range CNOT implementation + def long_range_cnot(phys_path): L = len(phys_path) - 1 if L <= 0: return @@ -111,11 +120,14 @@ def longe_range_cnot(phys_path): new_ops.append(qml.CNOT(wires=phys_path)) return mid = L // 2 + # forward for i in range(mid): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) + # backward for i in range(L, mid, -1): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i - 1]])) new_ops.append(qml.CNOT(wires=[phys_path[mid], phys_path[mid + 1]])) + # re-expand for i in range(mid + 1, L + 1): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i - 1]])) for i in range(mid - 1, -1, -1): @@ -124,85 +136,105 @@ def longe_range_cnot(phys_path): # Process each operation for idx, op in enumerate(ops_list): wires = list(op.wires) - if len(wires) == 2: + # Error on operators > 2 wires + if len(wires) > 2: + raise ValueError("All operations should act in less than 3 wires.") + + # CNOT routing + if len(wires) == 2 and op.name == "CNOT": + lc, lt = wires + pc, pt = mapping[lc], mapping[lt] + phys_path = get_path(pc, pt) + d = len(phys_path) - 1 + if d == 1: + new_ops.append(qml.CNOT(wires=[pc, pt])) + else: + mid = d // 2 + pctrl = next_partner[idx][lc] + ptrg = next_partner[idx][lt] + best_score = float("inf") + best_k1, best_k2 = 0, d + for k1 in range(mid + 1): + pos1 = phys_path[k1] + for k2 in range(mid, d + 1): + if k2 <= k1: + continue + pos2 = phys_path[k2] + score = 0 + if pctrl is not None: + score += get_dist(pos1, mapping[pctrl]) + if ptrg is not None: + score += get_dist(pos2, mapping[ptrg]) + if score < best_score or ( + score == best_score and (k2 - k1) < (best_k2 - best_k1) + ): + best_score, best_k1, best_k2 = score, k1, k2 + # SWAPs for control + for i in range(best_k1): + u, v = phys_path[i], phys_path[i + 1] + new_ops.append(qml.SWAP(wires=[u, v])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(u) is not None: + mapping[inv[u]] = v + if inv.get(v) is not None: + mapping[inv[v]] = u + # SWAPs for target + for i in range(d, best_k2, -1): + u, v = phys_path[i], phys_path[i - 1] + new_ops.append(qml.SWAP(wires=[u, v])) + inv = {pos: lg for lg, pos in mapping.items()} + if inv.get(u) is not None: + mapping[inv[u]] = v + if inv.get(v) is not None: + mapping[inv[v]] = u + # long-range CNOT + sub = phys_path[best_k1 : best_k2 + 1] + long_range_cnot(sub) + # Other 2-qubit gates + elif len(wires) == 2: l0, l1 = wires p0, p1 = mapping[l0], mapping[l1] phys_path = get_path(p0, p1) d = len(phys_path) - 1 - # Handle CNOT specifically - if op.name == "CNOT": - if d == 1: - new_ops.append(qml.CNOT(wires=[p0, p1])) - else: - mid = d // 2 - pc = find_next_partner(idx, l0) - pt = find_next_partner(idx, l1) - # independent optimal positions - best_k1 = min( - range(mid + 1), - key=lambda k: get_dist(phys_path[k], mapping[pc]) if pc else 0, - ) - best_k2 = min( - range(mid, d + 1), - key=lambda k: get_dist(phys_path[k], mapping[pt]) if pt else 0, - ) - # swaps control - for i in range(best_k1): - u, v = phys_path[i], phys_path[i + 1] - new_ops.append(qml.SWAP(wires=[u, v])) + if d == 1: + new_ops.append(op.map_wires({l0: p0, l1: p1})) + else: + npc = next_partner[idx][l0] + npt = next_partner[idx][l1] + best_score, best_edge = float("inf"), 0 + for b in range(d): + u, v = phys_path[b], phys_path[b + 1] + score = 0 + if npc is not None: + score += get_dist(u, mapping[npc]) + if npt is not None: + score += get_dist(v, mapping[npt]) + if score < best_score: + best_score, best_edge = score, b + left, right = phys_path[best_edge], phys_path[best_edge + 1] + while (mapping[l0], mapping[l1]) != (left, right): + c0, c1 = mapping[l0], mapping[l1] + if c0 != left: + nxt = phys_path[phys_path.index(c0) + 1] + new_ops.append(qml.SWAP(wires=[c0, nxt])) inv = {pos: lg for lg, pos in mapping.items()} - if inv.get(u): - mapping[inv[u]] = v - if inv.get(v): - mapping[inv[v]] = u - # swaps target - for i in range(d, best_k2, -1): - u, v = phys_path[i], phys_path[i - 1] - new_ops.append(qml.SWAP(wires=[u, v])) + if inv.get(c0) is not None: + mapping[inv[c0]] = nxt + if inv.get(nxt) is not None: + mapping[inv[nxt]] = c0 + else: + nxt = phys_path[phys_path.index(c1) - 1] + new_ops.append(qml.SWAP(wires=[c1, nxt])) inv = {pos: lg for lg, pos in mapping.items()} - if inv.get(u): - mapping[inv[u]] = v - if inv.get(v): - mapping[inv[v]] = u - longe_range_cnot(phys_path[best_k1 : best_k2 + 1]) - else: - # generic 2-qubit gate via adjacent swaps - if d == 1: - new_ops.append(op.map_wires({l0: p0, l1: p1})) - else: - npc = find_next_partner(idx, l0) - npt = find_next_partner(idx, l1) - best_edge = min( - range(d), - key=lambda b: (get_dist(phys_path[b], mapping[npc]) if npc else 0) - + (get_dist(phys_path[b + 1], mapping[npt]) if npt else 0), - ) - left, right = phys_path[best_edge], phys_path[best_edge + 1] - while (mapping[l0], mapping[l1]) != (left, right): - if mapping[l0] != left: - cur = mapping[l0] - nxt = phys_path[phys_path.index(cur) + 1] - new_ops.append(qml.SWAP(wires=[cur, nxt])) - inv = {pos: lg for lg, pos in mapping.items()} - if inv.get(cur): - mapping[inv[cur]] = nxt - if inv.get(nxt): - mapping[inv[nxt]] = cur - else: - cur = mapping[l1] - nxt = phys_path[phys_path.index(cur) - 1] - new_ops.append(qml.SWAP(wires=[cur, nxt])) - inv = {pos: lg for lg, pos in mapping.items()} - if inv.get(cur): - mapping[inv[cur]] = nxt - if inv.get(nxt): - mapping[inv[nxt]] = cur - new_ops.append(op.map_wires({l0: mapping[l0], l1: mapping[l1]})) - elif len(wires) > 2: - raise ValueError("All operations should act in less than 3 wires.") + if inv.get(c1) is not None: + mapping[inv[c1]] = nxt + if inv.get(nxt) is not None: + mapping[inv[nxt]] = c1 + new_ops.append(op.map_wires({l0: mapping[l0], l1: mapping[l1]})) + # Single-qubit gates else: new_ops.append(op.map_wires({q: mapping[q] for q in wires})) # Remap measurements new_meas = [m.map_wires({q: mapping[q] for q in m.wires}) for m in tape.measurements] - return [QuantumScript(new_ops, new_meas)], lambda res: res[0] + return [QuantumScript(new_ops, new_meas)], lambda results: results[0] From bb5cac1c294433fd7bd16bd914a1da666d19fe1f Mon Sep 17 00:00:00 2001 From: Guillermo Alonso-Linaje <65235481+KetpuntoG@users.noreply.github.com> Date: Wed, 7 May 2025 17:32:30 -0400 Subject: [PATCH 6/6] extra tests --- .../transforms/optimization/qubit_mapping.py | 3 +- .../test_optimization/test_qubit_mapping.py | 251 ++++++++++++++++-- 2 files changed, 227 insertions(+), 27 deletions(-) diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py index 26a863f6220..89711ce37f8 100644 --- a/pennylane/transforms/optimization/qubit_mapping.py +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -23,6 +23,7 @@ from pennylane.transforms import transform +# pylint: disable=too-many-statements @transform def qubit_mapping(tape, graph, init_mapping=None): """Qubit mapping transform with sliding window for dependency lookahead. @@ -134,7 +135,7 @@ def long_range_cnot(phys_path): new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) # Process each operation - for idx, op in enumerate(ops_list): + for idx, op in enumerate(ops_list): # pylint:disable=too-many-nested-blocks wires = list(op.wires) # Error on operators > 2 wires if len(wires) > 2: diff --git a/tests/transforms/test_optimization/test_qubit_mapping.py b/tests/transforms/test_optimization/test_qubit_mapping.py index 929f166d541..ed4837c5aac 100644 --- a/tests/transforms/test_optimization/test_qubit_mapping.py +++ b/tests/transforms/test_optimization/test_qubit_mapping.py @@ -53,32 +53,191 @@ def qfunc(): [ ( [ - qml.RY(2, wires=0), + qml.Hadamard(0), qml.CNOT(wires=[0, 1]), - qml.CNOT(wires=[0, 2]), - qml.RX(3, wires=1), - qml.CZ(wires=[0, 3]), - qml.CZ(wires=[0, 1]), - qml.RY(4, wires=2), - qml.SWAP(wires=[0, 2]), + qml.RY(0.5, wires=2), + qml.CZ(wires=[1, 3]), + qml.SWAP(wires=[3, 4]), + qml.RX(1.0, wires=5), + qml.CNOT(wires=[4, 6]), + qml.RZ(0.3, wires=7), + qml.CRY(0.9, wires=[6, 8]), + ], + [qml.expval(qml.Z(9))], + ), + ( + [ + qml.PauliX(1), + qml.RY(1.4, wires=3), + qml.CNOT(wires=[3, 5]), + qml.CRY(1.2, wires=[5, 2]), + qml.SWAP(wires=[2, 6]), + qml.RZ(2.1, wires=4), + qml.CNOT(wires=[4, 7]), + qml.Hadamard(8), + qml.RX(1.8, wires=9), ], [qml.expval(qml.X(0))], ), ( [ + qml.Hadamard(5), + qml.CNOT(wires=[5, 1]), + qml.RY(0.9, wires=1), + qml.RZ(1.0, wires=2), + qml.CZ(wires=[2, 3]), + qml.CRY(1.7, wires=[3, 6]), + qml.SWAP(wires=[6, 0]), + qml.RX(2.2, wires=9), + ], + [qml.expval(qml.Y(4))], + ), + ( + [ + qml.RX(1.1, wires=6), + qml.CNOT(wires=[6, 0]), qml.Hadamard(0), + qml.RZ(2.3, wires=8), + qml.CRY(0.6, wires=[8, 7]), + qml.SWAP(wires=[7, 3]), + qml.CZ(wires=[3, 5]), + qml.RY(1.9, wires=4), + ], + [qml.expval(qml.Z(2))], + ), + ( + [ + qml.PauliZ(9), + qml.CNOT(wires=[9, 8]), + qml.CRY(1.3, wires=[8, 7]), + qml.SWAP(wires=[7, 6]), + qml.RX(0.7, wires=5), + qml.CZ(wires=[5, 4]), + qml.RY(0.4, wires=3), + qml.RZ(1.5, wires=2), + ], + [qml.expval(qml.Y(1))], + ), + ( + [ qml.Hadamard(2), - qml.CRY(2, wires=[0, 3]), - qml.CNOT(wires=[2, 0]), - qml.SWAP(wires=[0, 2]), - qml.RX(3, wires=3), - qml.CZ(wires=[0, 3]), - qml.CZ(wires=[0, 2]), - qml.RY(4, wires=2), - qml.SWAP(wires=[0, 2]), + qml.CRY(0.8, wires=[2, 6]), + qml.CNOT(wires=[6, 4]), + qml.SWAP(wires=[4, 1]), + qml.RZ(1.2, wires=1), + qml.RY(0.9, wires=5), + qml.CZ(wires=[5, 9]), + ], + [qml.expval(qml.X(3))], + ), + ( + [ + qml.RY(1.1, wires=7), + qml.CNOT(wires=[7, 6]), + qml.PauliY(6), + qml.CRY(1.5, wires=[6, 3]), + qml.SWAP(wires=[3, 2]), + qml.RX(0.2, wires=2), + qml.Hadamard(0), + ], + [qml.expval(qml.Z(4))], + ), + ( + [ + qml.PauliX(5), + qml.CNOT(wires=[5, 7]), + qml.RZ(2.0, wires=7), + qml.CZ(wires=[7, 9]), + qml.RX(1.6, wires=9), + qml.CRY(1.9, wires=[9, 1]), + qml.RY(1.3, wires=0), ], [qml.expval(qml.Y(3))], ), + ( + [ + qml.Hadamard(4), + qml.CNOT(wires=[4, 8]), + qml.RX(1.0, wires=8), + qml.CRY(0.7, wires=[8, 6]), + qml.SWAP(wires=[6, 0]), + qml.RY(2.4, wires=1), + qml.RZ(0.5, wires=2), + ], + [qml.expval(qml.Z(3))], + ), + ( + [ + qml.PauliY(2), + qml.CZ(wires=[2, 5]), + qml.RY(1.0, wires=5), + qml.SWAP(wires=[5, 7]), + qml.CNOT(wires=[7, 9]), + qml.RX(0.9, wires=3), + qml.RZ(1.6, wires=6), + ], + [qml.expval(qml.X(0))], + ), + ( + [ + qml.RX(0.3, wires=1), + qml.CNOT(wires=[1, 3]), + qml.CRY(2.1, wires=[3, 2]), + qml.SWAP(wires=[2, 4]), + qml.Hadamard(4), + qml.RY(1.4, wires=6), + qml.CZ(wires=[6, 8]), + ], + [qml.expval(qml.Z(9))], + ), + ( + [ + qml.RY(1.5, wires=0), + qml.CNOT(wires=[0, 2]), + qml.CZ(wires=[2, 3]), + qml.PauliX(3), + qml.SWAP(wires=[3, 5]), + qml.CRY(0.6, wires=[5, 7]), + qml.RZ(2.2, wires=9), + ], + [qml.expval(qml.Y(8))], + ), + ( + [ + qml.Hadamard(6), + qml.RX(1.9, wires=6), + qml.CNOT(wires=[6, 0]), + qml.SWAP(wires=[0, 1]), + qml.RZ(1.0, wires=1), + qml.CRY(1.1, wires=[1, 4]), + qml.RY(0.8, wires=3), + ], + [qml.expval(qml.X(2))], + ), + ( + [ + qml.RZ(1.3, wires=8), + qml.CRY(1.8, wires=[8, 7]), + qml.CNOT(wires=[7, 6]), + qml.SWAP(wires=[6, 5]), + qml.PauliZ(5), + qml.RX(1.6, wires=4), + qml.Hadamard(3), + ], + [qml.expval(qml.Z(2))], + ), + ( + [ + qml.RY(2.0, wires=9), + qml.CNOT(wires=[9, 8]), + qml.RZ(1.7, wires=8), + qml.CRY(0.4, wires=[8, 6]), + qml.SWAP(wires=[6, 2]), + qml.Hadamard(0), + qml.RX(1.5, wires=1), + ], + [qml.expval(qml.X(3))], + ), ], ) def test_correctness_solution(self, ops, meas): @@ -89,9 +248,31 @@ def test_correctness_solution(self, ops, meas): meas, ) - graph = {"a": ["b", "d"], "b": ["a", "c"], "c": ["b"], "d": ["a"]} + graph = { + "a": ["b", "d", "e", "f"], + "b": ["a", "c"], + "c": ["b"], + "d": ["a"], + "e": ["a"], + "f": ["a"], + "g": ["a"], + "h": ["a"], + "i": ["a"], + "j": ["a"], + } - initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + initial_map = { + 0: "a", + 1: "b", + 2: "c", + 3: "d", + 4: "e", + 5: "f", + 6: "g", + 7: "h", + 8: "i", + 9: "j", + } transformed_qs = qubit_mapping(qs, graph, initial_map) dev = qml.device("default.qubit") @@ -146,16 +327,9 @@ def test_correctness_connectivity(self, ops, meas): ops, meas, ) - graph = { - "a": ["b", "d"], - "b": ["a", "c"], - "c": ["b"], - "d": ["a"], - } - - initial_map = {0: "a", 1: "b", 2: "c", 3: "d"} + graph = {"a": ["b", "d", "e"], "b": ["a", "c"], "c": ["b"], "d": ["a"], "e": ["a"]} - transformed_qs = qubit_mapping(qs, graph, initial_map) + transformed_qs = qubit_mapping(qs, graph) new_tape = transformed_qs[0][0] for op in new_tape.operations: @@ -245,3 +419,28 @@ def qfunc(): with pytest.raises(ValueError, match="Insufficient physical qubits"): transformed_qfunc = qubit_mapping(qfunc, graph) _ = qml.tape.make_qscript(transformed_qfunc)() + + def test_mapping_logical_physical_same(self): + """Test that if the name of all the logical and physical wires match, the initial mapping respects the positions""" + + def qfunc(): + qml.CNOT(wires=["d", "b"]) + qml.CNOT(wires=["a", "c"]) + qml.CZ(wires=["b", "d"]) + + graph = { + "a": ["b", "c", "d"], + "b": ["a", "c", "d"], + "c": ["a", "b", "d"], + "d": ["a", "b", "c"], + } + + transformed_qfunc = qubit_mapping(qfunc, graph) + + new_tape = qml.tape.make_qscript(transformed_qfunc)() + + assert new_tape.operations == [ + qml.CNOT(wires=["d", "b"]), + qml.CNOT(wires=["a", "c"]), + qml.CZ(wires=["b", "d"]), + ]