diff --git a/CHANGELOG.md b/CHANGELOG.md index 3886aaa4f..ad5762d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ releases may include breaking changes. ### Added +- ✨ Add Qiskit's `TrivialLayout`, `ElidePermutations`, `SabreSwap`, + `BasicSwap`, `LookaheadSwap`, `RemoveIdentityEquivalent`, and + `Optimize1qGatesSimpleCommutation` passes and the optional IBM-backed + `AIRouting` and `AIRouting_opt` passes to the RL actions ([#794]) + ([**@flowerthrower**]) - ✨ Expand and compact the RL observation with normalized OpenQASM operation frequencies and one-element `float32` qubit-count and depth arrays, and include measurements in the shared ML feature schema ([#758]) @@ -23,6 +28,8 @@ releases may include breaking changes. ### Changed +- 🐛 Make the `OptimizeCliffords` RL action collect standard Clifford gates + before optimizing them ([#794]) ([**@flowerthrower**]) - 🔥 Drop support for Python 3.10 ([#773]) ([**@denialhaag**]) - ♻️ Split RL actions package into `base` and `registry` modules ([#769]) ([**@denialhaag**]) @@ -93,6 +100,7 @@ for previous changelogs._ [#773]: https://github.com/munich-quantum-toolkit/predictor/pull/771 [#769]: https://github.com/munich-quantum-toolkit/predictor/pull/769 +[#794]: https://github.com/munich-quantum-toolkit/predictor/pull/794 [#758]: https://github.com/munich-quantum-toolkit/predictor/pull/758 [#755]: https://github.com/munich-quantum-toolkit/predictor/pull/755 [#731]: https://github.com/munich-quantum-toolkit/predictor/pull/731 diff --git a/UPGRADING.md b/UPGRADING.md index fd0caf47f..1965cc466 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,6 +6,35 @@ of changes including minor and patch releases, please refer to the ## [Unreleased] +### Expanded Qiskit action set + +The RL action space now includes the following Qiskit passes: + +- the `TrivialLayout` and `ElidePermutations` layout actions; +- the `SabreSwap`, `BasicSwap`, `LookaheadSwap`, and `AIRouting` routing + actions; +- the combined layout-and-routing action `AIRouting_opt`; and +- the `RemoveIdentityEquivalent` and `Optimize1qGatesSimpleCommutation` + optimization actions. + +`ElidePermutations` establishes a trivial layout in the same action so its +output permutation remains part of the canonical layout. `OptimizeCliffords` now +collects standard Clifford gates before optimizing and decomposes the result for +subsequent passes. + +`AIRouting` and `AIRouting_opt` are masked when IBM's optional +`qiskit-ibm-transpiler` package cannot be imported. MQT Predictor does not +install that package because its current release pins NetworkX 2.8.5 while MQT +Bench requires NetworkX 2.8.8 or newer, excludes Python 3.14, and imports Qiskit +internals removed in Qiskit 2.5. Consequently, there is currently no supported +MQT Predictor installation that enables these actions. A future compatible IBM +release can be loaded without changing the action schema. Its routing model is +downloaded on first use. + +Existing RL models must be retrained because the action-space size and the +indices of later actions have changed. Code that persists or selects actions by +numeric index must be updated. + ### RL observation features The RL observation now includes normalized frequencies for supported OpenQASM diff --git a/src/mqt/predictor/rl/actions/qiskit_actions.py b/src/mqt/predictor/rl/actions/qiskit_actions.py index 173d168cd..d8e1b3538 100644 --- a/src/mqt/predictor/rl/actions/qiskit_actions.py +++ b/src/mqt/predictor/rl/actions/qiskit_actions.py @@ -11,7 +11,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from functools import cache +from importlib import import_module +from typing import TYPE_CHECKING, Any, cast from qiskit.circuit import StandardEquivalenceLibrary from qiskit.circuit.library import ( @@ -37,24 +39,33 @@ from qiskit.transpiler import CouplingMap, PassManager, TranspileLayout from qiskit.transpiler.passes import ( ApplyLayout, + BasicSwap, BasisTranslator, Collect2qBlocks, + CollectCliffords, CommutativeCancellation, CommutativeInverseCancellation, ConsolidateBlocks, + Decompose, DenseLayout, Depth, + ElidePermutations, EnlargeWithAncilla, FixedPoint, FullAncillaAllocation, GatesInBasis, InverseCancellation, + LookaheadSwap, MinimumPoint, Optimize1qGatesDecomposition, + Optimize1qGatesSimpleCommutation, OptimizeCliffords, RemoveDiagonalGatesBeforeMeasure, + RemoveIdentityEquivalent, SabreLayout, + SabreSwap, Size, + TrivialLayout, UnitarySynthesis, VF2Layout, VF2PostLayout, @@ -81,6 +92,39 @@ logger = logging.getLogger("mqt-predictor") +_AI_ROUTING_ACTION_NAMES = frozenset({"AIRouting", "AIRouting_opt"}) + + +@cache +def _load_airouting() -> type[Any]: + """Load IBM's optional AI routing pass.""" + try: + module = import_module("qiskit_ibm_transpiler.ai.routing") + except ImportError as exc: + msg = "AIRouting requires a qiskit-ibm-transpiler installation compatible with this environment." + raise RuntimeError(msg) from exc + return cast("type[Any]", vars(module)["AIRouting"]) + + +def _airouting_pass(*, coupling_map: CouplingMap, layout_mode: str) -> Task: + """Construct IBM's local AI routing pass.""" + return _load_airouting()( + coupling_map=coupling_map, + optimization_level=3, + layout_mode=layout_mode, + local_mode=True, + ) + + +@cache +def _is_ai_routing_available() -> bool: + """Return whether IBM's AI routing pass can be imported.""" + try: + _load_airouting() + except RuntimeError: + return False + return True + def qiskit_optimization_actions() -> list[Action]: """Returns the Qiskit optimization actions.""" @@ -149,7 +193,11 @@ def qiskit_optimization_actions() -> list[Action]: "OptimizeCliffords", CompilationOrigin.QISKIT, PassType.OPT, - [OptimizeCliffords()], + [ + CollectCliffords(), + OptimizeCliffords(), + Decompose(gates_to_decompose="clifford", apply_synthesis=True), + ], preserves_layout=True, preserves_routing=False, preserves_synthesis=False, @@ -163,6 +211,32 @@ def qiskit_optimization_actions() -> list[Action]: preserves_routing=True, preserves_synthesis=False, ), + DeviceIndependentAction( + "RemoveIdentityEquivalent", + CompilationOrigin.QISKIT, + PassType.OPT, + [RemoveIdentityEquivalent()], + preserves_layout=True, + preserves_routing=True, + preserves_synthesis=True, + ), + DeferredDeviceAction( + "Optimize1qGatesSimpleCommutation", + CompilationOrigin.QISKIT, + PassType.OPT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + Optimize1qGatesSimpleCommutation( + basis=device.operation_names, + run_to_completion=True, + ) + ], + ), + preserves_layout=True, + preserves_routing=True, + preserves_synthesis=True, + ), ] @@ -249,9 +323,93 @@ def qiskit_layout_actions() -> list[Action]: ], ), ), + DeferredDeviceAction( + "TrivialLayout", + CompilationOrigin.QISKIT, + PassType.LAYOUT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + TrivialLayout(coupling_map=CouplingMap(device.build_coupling_map())), + FullAncillaAllocation(coupling_map=CouplingMap(device.build_coupling_map())), + EnlargeWithAncilla(), + ApplyLayout(), + ], + ), + ), + DeferredDeviceAction( + "ElidePermutations", + CompilationOrigin.QISKIT, + PassType.LAYOUT, + transpile_pass=lambda device: cast( + "list[Task]", + [ + ElidePermutations(), + TrivialLayout(coupling_map=CouplingMap(device.build_coupling_map())), + FullAncillaAllocation(coupling_map=CouplingMap(device.build_coupling_map())), + EnlargeWithAncilla(), + ApplyLayout(), + ], + ), + ), + ] + + +def qiskit_routing_actions() -> list[Action]: + """Return the Qiskit routing actions.""" + return [ + DeferredDeviceAction( + "SabreSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", [SabreSwap(coupling_map=CouplingMap(device.build_coupling_map()), heuristic="decay")] + ), + ), + DeferredDeviceAction( + "BasicSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", [BasicSwap(coupling_map=CouplingMap(device.build_coupling_map()))] + ), + ), + DeferredDeviceAction( + "LookaheadSwap", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + LookaheadSwap( + coupling_map=CouplingMap(device.build_coupling_map()), + search_depth=1, + search_width=1, + ) + ], + ), + ), ] +def qiskit_ai_routing_action() -> Action: + """Return IBM's AI routing action.""" + return DeferredDeviceAction( + "AIRouting", + CompilationOrigin.QISKIT, + PassType.ROUTING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="improve", + ) + ], + ), + ) + + def qiskit_mapping_action() -> Action: """Returns the Qiskit mapping action.""" return DeferredDeviceAction( @@ -264,6 +422,24 @@ def qiskit_mapping_action() -> Action: ) +def qiskit_ai_mapping_action() -> Action: + """Return the combined AI layout and routing action.""" + return DeferredDeviceAction( + "AIRouting_opt", + CompilationOrigin.QISKIT, + PassType.MAPPING, + transpile_pass=lambda device: cast( + "list[Task]", + [ + _airouting_pass( + coupling_map=device.build_coupling_map(), + layout_mode="optimize", + ), + ], + ), + ) + + def qiskit_synthesis_action() -> Action: """Returns the Qiskit synthesis action.""" return DeferredDeviceAction( @@ -365,16 +541,22 @@ def run_qiskit_action( if action.pass_type in {PassType.LAYOUT, PassType.MAPPING, PassType.FINAL_OPT}: altered_qc, layout = _postprocess_layout_action(action, pm.property_set, altered_qc, layout, input_qubit_count) elif action.pass_type == PassType.ROUTING and layout and pm.property_set["final_layout"] is not None: - layout.final_layout = pm.property_set["final_layout"] + routing_layout = pm.property_set["final_layout"] + layout.final_layout = ( + layout.final_layout.compose(routing_layout, circuit.qubits) + if layout.final_layout is not None + else routing_layout + ) if altered_qc.count_ops().get("unitary"): # Custom "unitary" gates can not be processed further by other passes altered_qc = altered_qc.decompose(gates_to_decompose="unitary") - return altered_qc, layout def is_qiskit_action_available(action: Action, device: Target) -> bool: """Return whether a Qiskit action is available for the current device.""" + if action.name in _AI_ROUTING_ACTION_NAMES and not _is_ai_routing_available(): + return False # Only allow VF2PostLayout if "ibm" is in the device name # TODO: Why? return action.name != "VF2PostLayout" or "ibm" in device.description diff --git a/src/mqt/predictor/rl/actions/registry.py b/src/mqt/predictor/rl/actions/registry.py index 5aa6c73ad..95b239839 100644 --- a/src/mqt/predictor/rl/actions/registry.py +++ b/src/mqt/predictor/rl/actions/registry.py @@ -51,7 +51,10 @@ def get_actions_by_pass_type() -> dict[PassType, list[Action]]: for _action in ( *qiskit_actions.qiskit_layout_actions(), + *qiskit_actions.qiskit_routing_actions(), + qiskit_actions.qiskit_ai_routing_action(), qiskit_actions.qiskit_mapping_action(), + qiskit_actions.qiskit_ai_mapping_action(), qiskit_actions.qiskit_synthesis_action(), qiskit_actions.qiskit_o3_action(), *qiskit_actions.qiskit_optimization_actions(), diff --git a/tests/compilation/test_helper_rl.py b/tests/compilation/test_helper_rl.py index 5acc1f75e..44e331eb4 100644 --- a/tests/compilation/test_helper_rl.py +++ b/tests/compilation/test_helper_rl.py @@ -19,21 +19,24 @@ from mqt.bench import BenchmarkLevel, get_benchmark from mqt.bench.targets import get_device from qiskit import transpile -from qiskit.transpiler import PassManager +from qiskit.transpiler import CouplingMap, PassManager from qiskit.transpiler.passes.layout.vf2_post_layout import VF2PostLayoutStopReason from mqt.predictor.rl.actions import ( + CompilationOrigin, PassType, get_actions_by_pass_type, + qiskit_actions, ) from mqt.predictor.rl.actions.bqskit_actions import bqskit_to_qiskit, get_bqskit_native_gates -from mqt.predictor.rl.actions.qiskit_actions import postprocess_vf2postlayout +from mqt.predictor.rl.actions.qiskit_actions import is_qiskit_action_available, postprocess_vf2postlayout from mqt.predictor.rl.helper import create_feature_dict, get_path_trained_model, get_path_training_circuits from mqt.predictor.utils import get_openqasm_gates if TYPE_CHECKING: from collections.abc import Callable + import pytest from qiskit.passmanager.base_tasks import Task from qiskit.transpiler import Target @@ -99,6 +102,56 @@ def test_bqskit_to_qiskit_converts_u1q_to_r_gate() -> None: assert qc.data[0].operation.params == [0.1, 0.2] +def test_ai_routing_action_factories(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the IBM AI routing action wiring without loading its model.""" + + class FakeAIRouting: + """Record the arguments used to construct an AI routing pass.""" + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + + monkeypatch.setattr(qiskit_actions, "_load_airouting", lambda: FakeAIRouting) + + device = get_device("ibm_falcon_27") + actions = { + action.name: action + for action in ( + *get_actions_by_pass_type()[PassType.ROUTING], + *get_actions_by_pass_type()[PassType.MAPPING], + ) + } + + routing_action = actions["AIRouting"] + assert routing_action.origin == CompilationOrigin.QISKIT + assert routing_action.pass_type == PassType.ROUTING + routing_factory = cast("Callable[[Target], list[Task]]", routing_action.transpile_pass) + routing_passes = routing_factory(device) + assert len(routing_passes) == 1 + ai_routing = cast("FakeAIRouting", routing_passes[0]) + routing_coupling_map = cast("CouplingMap", ai_routing.kwargs["coupling_map"]) + assert routing_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_routing.kwargs["optimization_level"] == 3 + assert ai_routing.kwargs["layout_mode"] == "improve" + assert ai_routing.kwargs["local_mode"] is True + + mapping_action = actions["AIRouting_opt"] + assert mapping_action.origin == CompilationOrigin.QISKIT + assert mapping_action.pass_type == PassType.MAPPING + mapping_factory = cast("Callable[[Target], list[Task]]", mapping_action.transpile_pass) + mapping_passes = mapping_factory(device) + assert len(mapping_passes) == 1 + ai_mapping = cast("FakeAIRouting", mapping_passes[0]) + mapping_coupling_map = cast("CouplingMap", ai_mapping.kwargs["coupling_map"]) + assert mapping_coupling_map.get_edges() == device.build_coupling_map().get_edges() + assert ai_mapping.kwargs["optimization_level"] == 3 + assert ai_mapping.kwargs["layout_mode"] == "optimize" + assert ai_mapping.kwargs["local_mode"] is True + + monkeypatch.setattr(qiskit_actions, "_is_ai_routing_available", lambda: False) + assert not is_qiskit_action_available(routing_action, device) + + def test_vf2_layout_and_postlayout() -> None: """Test the VF2Layout and VF2PostLayout passes.""" qc = get_benchmark("ghz", BenchmarkLevel.ALG, 3) diff --git a/tests/compilation/test_predictor_rl.py b/tests/compilation/test_predictor_rl.py index c057431d6..b816369dd 100644 --- a/tests/compilation/test_predictor_rl.py +++ b/tests/compilation/test_predictor_rl.py @@ -20,6 +20,7 @@ from qiskit import QuantumCircuit from qiskit.circuit.library import CXGate from qiskit.qasm2 import dump +from qiskit.quantum_info import Clifford from qiskit.transpiler import InstructionProperties, Layout, Target, TranspileLayout from qiskit.transpiler.passes import GatesInBasis @@ -30,7 +31,6 @@ DeviceIndependentAction, PassType, get_actions_by_pass_type, - qiskit_actions, register_action, ) from mqt.predictor.rl.actions import registry as actions_registry_module @@ -263,46 +263,54 @@ def test_predictor_env_actions_for_mdp_state( assert set(valid_actions) == expected_actions -def test_predictor_env_qiskit_routing_updates_final_layout(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that Qiskit routing actions update the tracked final layout.""" - device = get_device("ibm_falcon_27") - env = predictorenv_module.PredictorEnv(device=device) - qc = QuantumCircuit(2) - qc.cx(0, 1) - env.reset(qc) - - initial_layout = Layout({qubit: index for index, qubit in enumerate(qc.qubits)}) - final_layout = Layout({qc.qubits[0]: 1, qc.qubits[1]: 0}) - env.layout = TranspileLayout( - initial_layout=initial_layout, - input_qubit_mapping={qubit: index for index, qubit in enumerate(qc.qubits)}, - final_layout=None, - _output_qubit_list=qc.qubits, - _input_qubit_count=qc.num_qubits, +def test_predictor_env_qiskit_routing_composes_final_layout() -> None: + """Test that Qiskit routing composes an existing output permutation.""" + target = Target(num_qubits=3, description="bidirectional line") + target.add_instruction( + CXGate(), + { + (0, 1): InstructionProperties(), + (1, 0): InstructionProperties(), + (1, 2): InstructionProperties(), + (2, 1): InstructionProperties(), + }, ) - - class FakePassManager: - """Minimal PassManager replacement that exposes a final layout.""" - - def __init__(self, _passes: object) -> None: - self.property_set = {"final_layout": final_layout} - - def run(self, circuit: QuantumCircuit) -> QuantumCircuit: - return circuit - - monkeypatch.setattr(qiskit_actions, "PassManager", FakePassManager) - action = DeviceIndependentAction( - name="SyntheticQiskitRouting", - pass_type=PassType.ROUTING, - transpile_pass=[], - origin=CompilationOrigin.QISKIT, + env = predictorenv_module.PredictorEnv(device=target) + circuit = QuantumCircuit(3) + circuit.swap(0, 1) + circuit.cx(1, 2) + env.reset(circuit) + + elide_index = next(index for index, action in env.action_set.items() if action.name == "ElidePermutations") + env.state = env.apply_action(elide_index) + assert env.layout is not None + assert env.layout.final_index_layout() == [1, 0, 2] + + basic_swap_index = next(index for index, action in env.action_set.items() if action.name == "BasicSwap") + env.state = env.apply_action(basic_swap_index) + assert env.layout.final_index_layout() == [0, 1, 2] + + +def test_clifford_decomposition_is_scoped_to_optimize_cliffords() -> None: + """Test that only OptimizeCliffords decomposes Clifford operations.""" + env = predictorenv_module.PredictorEnv(device=get_device("ibm_falcon_27")) + definition = QuantumCircuit(1) + definition.h(0) + circuit = QuantumCircuit(1) + circuit.append(Clifford(definition), [0]) + env.reset(circuit) + + remove_identity_index = next( + index for index, action in env.action_set.items() if action.name == "RemoveIdentityEquivalent" ) - routing_action_index = next(iter(env.actions_routing_indices)) - env.action_set[routing_action_index] = action - altered_qc = env.apply_action(action_index=routing_action_index) + unchanged = env.apply_action(remove_identity_index) + assert unchanged.count_ops() == {"clifford": 1} - assert altered_qc is env.state - assert env.layout.final_layout is final_layout + optimize_cliffords_index = next( + index for index, action in env.action_set.items() if action.name == "OptimizeCliffords" + ) + optimized = env.apply_action(optimize_cliffords_index) + assert "clifford" not in optimized.count_ops() def test_register_action(monkeypatch: pytest.MonkeyPatch) -> None: