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..741447d6281 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 diff --git a/pennylane/transforms/optimization/qubit_mapping.py b/pennylane/transforms/optimization/qubit_mapping.py new file mode 100644 index 00000000000..89711ce37f8 --- /dev/null +++ b/pennylane/transforms/optimization/qubit_mapping.py @@ -0,0 +1,241 @@ +# 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 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 + + +# pylint: disable=too-many-statements +@transform +def qubit_mapping(tape, graph, init_mapping=None): + """Qubit mapping transform with sliding window for dependency lookahead. + + 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. + 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 `. + + **Example** + + .. code-block:: python + + dev = qml.device('default.qubit') + + graph = {"a": ["b"], "b": ["a", "c"], "c": ["b"]} + + @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────┤ + """ + # 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) + + # 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 + 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} logical wires." + ) + else: + mapping = init_mapping.copy() + + # Precompute future two-qubit dependencies + future = {w: [] for w in logical_qubits} + ops_list = list(tape.operations) + 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 = [] + + # Long-range CNOT implementation + def long_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 + 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): + new_ops.append(qml.CNOT(wires=[phys_path[i], phys_path[i + 1]])) + + # Process each operation + 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: + 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 + 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(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(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 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..ed4837c5aac --- /dev/null +++ b/tests/transforms/test_optimization/test_qubit_mapping.py @@ -0,0 +1,446 @@ +# 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.Hadamard(0), + qml.CNOT(wires=[0, 1]), + 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(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): + """Test that the output is not modified by the transform""" + + qs = qml.tape.QuantumScript( + ops, + meas, + ) + + 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", + 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") + + 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", "e"], "b": ["a", "c"], "c": ["b"], "d": ["a"], "e": ["a"]} + + transformed_qs = qubit_mapping(qs, graph) + 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)() + + 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"]), + ]