diff --git a/perceval/backends/__init__.py b/perceval/backends/__init__.py index ee14e3490..078880c33 100644 --- a/perceval/backends/__init__.py +++ b/perceval/backends/__init__.py @@ -35,6 +35,7 @@ from ._slos import SLOSBackend from ._slap import SLAPBackend from ._slos_exqalibur import SLOSExqaliburBackend +from ._slos_mpi import SLOSMPIBackend from ._stepper import StepperBackend @@ -47,6 +48,7 @@ "SLAP": SLAPBackend, "SLOS_LEGACY": SLOSBackend, "SLOS": SLOSExqaliburBackend, + "SLOS_MPI": SLOSMPIBackend, } diff --git a/perceval/backends/_slos_mpi.py b/perceval/backends/_slos_mpi.py new file mode 100644 index 000000000..ef59dbc83 --- /dev/null +++ b/perceval/backends/_slos_mpi.py @@ -0,0 +1,116 @@ +# MIT License +# +# Copyright (c) 2026 Quandela +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import exqalibur as xq + +from perceval.components import ACircuit +from perceval.utils import BSDistribution, FockState, StateVector +from perceval.utils.postselect import PostSelect + +from ._abstract_backends import AStrongSimulationBackend + +try: + # Importing mpi4py initializes MPI before the native backend is constructed. + from mpi4py import MPI +except ImportError: # pragma: no cover - depends on the optional MPI environment + MPI = None + + +class SLOSMPIBackend(AStrongSimulationBackend): + """Rank-local wrapper around Exqalibur's distributed SLOS backend. + + Result-producing methods are collective and must be called in the same + order on every MPI rank. Amplitude arrays and state vectors contain only + the slice owned by the calling rank. Probability distributions are merged + across ranks so they satisfy Perceval's backend contract. + """ + + def __init__(self, mask=None): + super().__init__() + if MPI is None: + raise RuntimeError("SLOS_MPI requires mpi4py") + if not hasattr(xq, "SLOS_MPI"): + raise RuntimeError("Exqalibur was built without MPI support") + self._slos = xq.SLOS_MPI() + if mask: + self.set_mask(mask) + + def set_circuit(self, circuit: ACircuit): + super().set_circuit(circuit) + self._slos.set_unitary(self._umat) + + def set_input_state(self, input_state: FockState): + super().set_input_state(input_state) + self._slos.set_input_state(input_state) + + def _init_mask(self): + super()._init_mask() + self._slos.set_mask(self._mask) + + def set_post_select(self, post_selection: PostSelect): + self._slos.set_post_select(post_selection) + + def _local_amplitudes(self) -> dict[FockState, complex]: + return dict(zip(self._slos.get_states(), self._slos.all_amplitudes())) + + def prob_amplitude(self, output_state: FockState) -> complex: + return self._local_amplitudes().get(output_state, 0j) + + def probability(self, output_state: FockState) -> float: + return abs(self.prob_amplitude(output_state)) ** 2 + + def prob_distribution(self) -> BSDistribution: + local_distribution = [ + (tuple(state), probability) + for state, probability in self._slos.distribution().items() + ] + result = BSDistribution() + for rank_distribution in MPI.COMM_WORLD.allgather(local_distribution): + for occupations, probability in rank_distribution: + result.add(FockState(occupations), probability) + return result + + def all_prob_ampli(self) -> list[complex]: + return self._slos.all_amplitudes() + + def all_prob(self, input_state: FockState = None) -> list[float]: + self._slos.set_input_state(input_state or self._input_state) + return self._slos.all_probabilities() + + def evolve(self) -> StateVector: + self._slos.set_input_state(self._input_state) + result = StateVector() + for output_state, amplitude in zip(self._slos.get_states(), self._slos.all_amplitudes()): + result += output_state * amplitude + return result + + @property + def name(self) -> str: + return "SLOS_MPI" + + @property + def rank(self) -> int: + return self._slos.get_rank() + + @property + def process_count(self) -> int: + return self._slos.get_process_count() diff --git a/perceval/runtime/remote_processor.py b/perceval/runtime/remote_processor.py new file mode 100644 index 000000000..e08175565 --- /dev/null +++ b/perceval/runtime/remote_processor.py @@ -0,0 +1,10 @@ +"""Backward-compatible remote-processor exports. + +``RemoteProcessor`` was replaced by the remote-computer API. Keep the +performance metadata key importable for integrations that still target the +former module path. +""" + +from .communication_layer import PERFS_KEY + +__all__ = ["PERFS_KEY"] diff --git a/setup.py b/setup.py index f5dae915c..57caa98b6 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,10 @@ def addRemoteRootToImages(content, root): 'multipledispatch<2', 'drawsvg>=2.0', 'requests<3', 'networkx>=3.1,<4', 'latexcodec<4', 'platformdirs<5', 'tqdm', ], - extras_require={"kipu": ["qhub-api>=2.0.0,<3"]}, + extras_require={ + "kipu": ["qhub-api>=2.0.0,<3"], + "mpi": ["mpi4py>=4,<5"], + }, setup_requires=["scmver"], python_requires=">=3.10,<3.15", scmver=True diff --git a/tests/simulators/test_simulator_factory.py b/tests/simulators/test_simulator_factory.py index abf9c4427..6e0f5bfa0 100644 --- a/tests/simulators/test_simulator_factory.py +++ b/tests/simulators/test_simulator_factory.py @@ -27,15 +27,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from perceval.simulators import SimulatorFactory, Simulator, DelaySimulator, LossSimulator, PolarizationSimulator +from perceval.simulators import (SimulatorFactory, Simulator, ExqaliburSimulator, + DelaySimulator, LossSimulator, PolarizationSimulator) from perceval.components import BS, PBS, Unitary, PS, TD, LC from perceval.runtime import Processor from perceval.backends._slos import SLOSBackend from perceval.backends._slos_exqalibur import SLOSExqaliburBackend +from perceval.backends._slos_mpi import SLOSMPIBackend from perceval.backends._naive import NaiveBackend from perceval.utils import BasicState import numpy as np +import exqalibur as xq +import pytest def test_create_simulator_from_circuit(): @@ -108,3 +112,75 @@ def test_create_simulator_from_complex_processor(): assert isinstance(simu._simulator, DelaySimulator) assert isinstance(simu._simulator._simulator, PolarizationSimulator) assert isinstance(simu._simulator._simulator._simulator, Simulator) + +@pytest.mark.skipif(not hasattr(xq, "SLOS_MPI"), reason="Exqalibur was built without MPI support") +def test_create_simulator_with_slos_mpi(): + pytest.importorskip("mpi4py") + simu = SimulatorFactory.build(BS(), "SLOS_MPI") + assert isinstance(simu, Simulator) + assert not isinstance(simu, ExqaliburSimulator) + assert isinstance(simu._backend, SLOSMPIBackend) + + results = simu.probs(BasicState("|1,0>")) + assert np.isclose(sum(results.values()), 1) + + +@pytest.mark.skipif(not hasattr(xq, "SLOS_MPI"), reason="Exqalibur was built without MPI support") +def test_create_simulator_from_polarized_circuit_with_slos_mpi(): + pytest.importorskip("mpi4py") + simu = SimulatorFactory.build(PBS(), "SLOS_MPI") + assert isinstance(simu, PolarizationSimulator) + assert isinstance(simu._simulator, Simulator) + assert isinstance(simu._simulator._backend, SLOSMPIBackend) + + +@pytest.mark.skipif(not hasattr(xq, "SLOS_MPI"), reason="Exqalibur was built without MPI support") +def test_create_simulator_from_components_with_slos_mpi(): + pytest.importorskip("mpi4py") + + unitary_components = [ + ((0, 1), BS()), + ((1,), PS(phi=2)), + ((0, 1), BS()), + ] + simu = SimulatorFactory.build(unitary_components, "SLOS_MPI") + assert isinstance(simu, Simulator) + assert isinstance(simu._backend, SLOSMPIBackend) + + delayed_components = [ + ((0, 1), BS()), + ((1,), TD(dt=2)), + ((0, 1), BS()), + ] + simu = SimulatorFactory.build(delayed_components, "SLOS_MPI") + assert isinstance(simu, DelaySimulator) + assert isinstance(simu._simulator, Simulator) + assert isinstance(simu._simulator._backend, SLOSMPIBackend) + + lossy_components = [ + ((0, 1), BS()), + ((1,), LC(loss=0.2)), + ((0, 1), BS()), + ] + simu = SimulatorFactory.build(lossy_components, "SLOS_MPI") + assert isinstance(simu, LossSimulator) + assert isinstance(simu._simulator, Simulator) + assert isinstance(simu._simulator._backend, SLOSMPIBackend) + + +@pytest.mark.skipif(not hasattr(xq, "SLOS_MPI"), reason="Exqalibur was built without MPI support") +def test_create_simulator_from_complex_processor_with_slos_mpi(): + pytest.importorskip("mpi4py") + processor = Processor("SLOS_MPI", 2) + processor.add(0, BS()) + processor.add(0, TD(dt=1)) + processor.add(0, PS(phi=0.5)) + processor.add(1, LC(loss=0.1)) + processor.add(0, PBS()) + + simu = SimulatorFactory.build(processor) + assert isinstance(simu, LossSimulator) + assert isinstance(simu._simulator, DelaySimulator) + assert isinstance(simu._simulator._simulator, PolarizationSimulator) + assert isinstance(simu._simulator._simulator._simulator, Simulator) + assert isinstance(simu._simulator._simulator._simulator._backend, SLOSMPIBackend)