From f766cb331c55ab1b03dc8911742b5fa5d0e19ea6 Mon Sep 17 00:00:00 2001 From: Nicolai Haug <39106781+nicolossus@users.noreply.github.com> Date: Fri, 1 Dec 2023 11:57:36 +0100 Subject: [PATCH 01/20] WIP: MPI testing framework --- testsuite/pytests/utilities/mpi_wrapper.py | 156 ++++++++++++++++++ testsuite/pytests/utilities/mpi_wrapper2.py | 66 ++++++++ .../pytests/utilities/test_brunel2000_mpi.py | 103 ++++++++++++ testsuite/pytests/utilities/test_mpi_dev.py | 80 +++++++++ 4 files changed, 405 insertions(+) create mode 100644 testsuite/pytests/utilities/mpi_wrapper.py create mode 100644 testsuite/pytests/utilities/mpi_wrapper2.py create mode 100644 testsuite/pytests/utilities/test_brunel2000_mpi.py create mode 100644 testsuite/pytests/utilities/test_mpi_dev.py diff --git a/testsuite/pytests/utilities/mpi_wrapper.py b/testsuite/pytests/utilities/mpi_wrapper.py new file mode 100644 index 0000000000..7a47ae0a66 --- /dev/null +++ b/testsuite/pytests/utilities/mpi_wrapper.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# +# mpi_wrapper.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + +import ast +import functools +import inspect +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +from decorator import decorator + +# from mpi4py import MPI + + +class DecoratorParserBase: + """ + Base class that parses the test module to retrieve imports, test code and + test parametrization. + """ + + def __init__(self): + pass + + def _parse_import_statements(self, caller_fname): + with open(caller_fname, "r") as f: + tree = ast.parse(f.read(), caller_fname) + + modules = [] + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + modules.append(ast.unparse(node).encode()) + + return modules + + def _parse_func_source(self, func): + lines, _ = inspect.getsourcelines(func) + # remove decorators and encode + func_src = [line.encode() for line in lines if not line.startswith("@")] + return func_src + + def _params_as_str(self, *args, **kwargs): + params = "" + if args: + params += ", ".join(f"{arg}" for arg in args) + if kwargs: + if args: + params += ", " + params += ", ".join(f"{key}={value}" for key, value in kwargs.items()) + return params + + +class mpi_assert_equal_df(DecoratorParserBase): + """ + docs + """ + + def __init__(self, procs_lst): + if not isinstance(procs_lst, list): + # TODO: Instead of passing the number of MPI procs to run explicitly, i.e., [1, 2, 4], another + # option is to pass the max number, e.g., 4, and handle the range internally + msg = "'mpi_assert_equal_df' decorator requires the number of MPI procs to test to be passed as a list" + raise TypeError(msg) + + # TODO: check if len(procs_lst) >= 2 (not yet implemented for debugging purposes) + + self._procs_lst = procs_lst + self._caller_fname = inspect.stack()[1].filename + + def __call__(self, func): + def wrapper(func, *args, **kwargs): + # TODO: replace path setup below with the following: + # with tempfile.TemporaryDirectory() as tmpdir: + self._path = Path("./tmpdir") + self._path.mkdir(parents=True, exist_ok=True) + + # Write the relevant code from test module to a new, temporary (TODO) + # runner script + with open(self._path / "runner.py", "wb") as fp: + self._write_runner(fp, func, *args, **kwargs) + + for procs in self._procs_lst: + # TODO: MPI and subprocess does not seem to play well together + """ + subprocess.run(['python', fp.name], + capture_output=True, + check=True + ) + """ + + """ + res = subprocess.run( + ["mpirun", "-np", str(procs), "python", fp.name], capture_output=True, check=True, env=os.environ + ) + + """ + res = subprocess.run( + # ["mpiexec", "-n", str(procs), sys.executable, fp.name], + ["mpirun", "-np", str(procs), "python", fp.name], + capture_output=True, + check=True, + shell=True, + env=os.environ, + ) + + print(res) + + # Here we need to assert that dfs from all runs are equal + + return decorator(wrapper, func) + + def _main_block_equal_df(self, func, *args, **kwargs): + main_block = "\n" + main_block += "if __name__ == '__main__':" + main_block += "\n\t" + main_block += f"df = {func.__name__}({self._params_as_str(*args, **kwargs)})" + main_block += "\n\t" + + main_block += f"path = '{self._path}'" + main_block += "\n\t" + + # Write output df to csv (will be compared later) + # main_block += "df.to_csv(f'{path}/df_{nest.NumProcesses()}-{nest.Rank()}.csv', index=False)" + + return main_block.encode() + + def _write_runner(self, fp, func, *args, **kwargs): + # TODO: most of this can probably be in base class + fp.write(b"\n".join(self._parse_import_statements(self._caller_fname))) + fp.write(b"\n\n") + fp.write(b"".join(self._parse_func_source(func))) + + # TODO: only the main block needs to be changed between runner runs + fp.write(self._main_block_equal_df(func, *args, **kwargs)) diff --git a/testsuite/pytests/utilities/mpi_wrapper2.py b/testsuite/pytests/utilities/mpi_wrapper2.py new file mode 100644 index 0000000000..ecf358b6be --- /dev/null +++ b/testsuite/pytests/utilities/mpi_wrapper2.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# mpi_wrapper2.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + +import inspect +import os +import subprocess +from functools import wraps + + +# NOTE: This approach doesn't seem to work +def mpi_wrapper(func): + @wraps(func) + def wrapper(*args, **kwargs): + num_processes = 1 + + file = inspect.getfile(func) + module = func.__module__ + path = os.path.abspath(file) + + print("module.count('.'):", module.count(".")) + + for _ in range(module.count(".")): + print("path =", path) + path = os.path.split(path)[0] + + command = [ + "mpiexec", + "-n", + str(num_processes), + "-wdir", + path, + "python", + "-c", + f"from {module} import *; {func.__name__}()", + ] + + # f"import {module} as module; module.{func.__name__}()", + + print(func.__name__) + # print(module.func.__name__.original()) + + subprocess.run(command, capture_output=True, check=True, env=os.environ) + + print("args:", args, "kwargs:", kwargs) + + # return func(*args, **kwargs) + + return wrapper diff --git a/testsuite/pytests/utilities/test_brunel2000_mpi.py b/testsuite/pytests/utilities/test_brunel2000_mpi.py new file mode 100644 index 0000000000..b694eba0af --- /dev/null +++ b/testsuite/pytests/utilities/test_brunel2000_mpi.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# +# test_brunel2000_mpi.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + +import nest +import numpy as np +import pandas as pd +import pytest +from mpi_wrapper import mpi_assert_equal_df + +# from mpi4py import MPI + + +@mpi_assert_equal_df([1, 2]) +def test_brunel2000(): + """Implementation of the sparsely connected recurrent network described by + Brunel (2000). + + References + ---------- + Brunel N, Dynamics of Sparsely Connected Networks of Excitatory and + Inhibitory Spiking Neurons, Journal of Computational Neuroscience 8, + 183-208 (2000). + """ + + nest.ResetKernel() + + nest.set(total_num_virtual_procs=2) + + # Model parameters + NE = 10_000 # number of excitatory neurons + NI = 2_500 # number of inhibitory neurons + CE = 1_000 # number of excitatory synapses per neuron + CI = 250 # number of inhibitory synapses per neuron + N_rec = 10 # number of (excitatory) neurons to record + D = 1.5 # synaptic delay, all connections [ms] + JE = 0.1 # peak of EPSP [mV] + eta = 2.0 # external rate relative to threshold rate + g = 5.0 # ratio inhibitory weight/excitatory weight + JI = -g * JE # peak of IPSP [mV] + + neuron_params = { + "tau_m": 20, # membrance time constant [ms] + "t_ref": 2.0, # refractory period [ms] + "C_m": 250.0, # membrane capacitance [pF] + "E_L": 0.0, # resting membrane potential [mV] + "V_th": 20.0, # threshold potential [mV] + "V_reset": 0.0, # reset potential [mV] + } + + # Threshold rate; the external rate needed for a neuron to reach + # threshold in absence of feedback + nu_thresh = neuron_params["V_th"] / (JE * CE * neuron_params["tau_m"]) + + # External firing rate; firing rate of a neuron in the external population + nu_ext = eta * nu_thresh + + # Build network + enodes = nest.Create("iaf_psc_delta", NE, params=neuron_params) + inodes = nest.Create("iaf_psc_delta", NI, params=neuron_params) + ext = nest.Create("poisson_generator", 1, params={"rate": nu_ext * CE * 1000.0}) + srec = nest.Create("spike_recorder", 1) + + nest.CopyModel("static_synapse", "esyn", params={"weight": JE, "delay": D}) + nest.CopyModel("static_synapse", "isyn", params={"weight": JI, "delay": D}) + + nest.Connect(ext, enodes, conn_spec="all_to_all", syn_spec="esyn") + nest.Connect(ext, inodes, conn_spec="all_to_all", syn_spec="esyn") + nest.Connect(enodes[:N_rec], srec) + nest.Connect( + enodes, + enodes + inodes, + conn_spec={"rule": "fixed_indegree", "indegree": CE, "allow_autapses": False, "allow_multapses": True}, + syn_spec="esyn", + ) + nest.Connect( + inodes, + enodes + inodes, + conn_spec={"rule": "fixed_indegree", "indegree": CI, "allow_autapses": False, "allow_multapses": True}, + syn_spec="isyn", + ) + + # Simulate network + nest.Simulate(200.0) + + return pd.DataFrame.from_records(srec.events) diff --git a/testsuite/pytests/utilities/test_mpi_dev.py b/testsuite/pytests/utilities/test_mpi_dev.py new file mode 100644 index 0000000000..a1190e32cd --- /dev/null +++ b/testsuite/pytests/utilities/test_mpi_dev.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# +# test_mpi_dev.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + + +# TODO: delete this development file + +# import nest +import pandas as pd +import pytest +from mpi_wrapper import mpi_assert_equal_df +from mpi_wrapper2 import mpi_wrapper + +""" +@pytest.mark.parametrize("n_nrns", [2]) +@mpi_assert_equal_df([1, 2]) +def test_func(n_nrns): + nest.ResetKernel() + nest.total_num_virtual_procs = 2 + nrns = nest.Create("iaf_psc_alpha", n_nrns) + sinj = nest.Create("spike_train_injector", params={"spike_times": [1.0, 3.0, 5.0]}) + srec = nest.Create("spike_recorder") + + nest.Connect(sinj, nrns, syn_spec={"weight": 2000.0}) + nest.Connect(nrns, srec) + + nest.Simulate(10.0) + + df = pd.DataFrame.from_records(srec.events) + + return df + + + +# @pytest.mark.parametrize("n_nrns", [2]) +@mpi_wrapper +def test_func(): + n_nrns = 2 + nest.ResetKernel() + nest.total_num_virtual_procs = 2 + nrns = nest.Create("iaf_psc_alpha", n_nrns) + sinj = nest.Create("spike_train_injector", params={"spike_times": [1.0, 3.0, 5.0]}) + srec = nest.Create("spike_recorder") + + nest.Connect(sinj, nrns, syn_spec={"weight": 2000.0}) + nest.Connect(nrns, srec) + + nest.Simulate(10.0) + + df = pd.DataFrame.from_records(srec.events) + + return df +""" + + +# @mpi_wrapper +@mpi_assert_equal_df([1]) +def test_func2(): + a = 2 + b = 4 + # print("I AM TEST") + + return a + b From 413a6ce7531922fbb2f5047a2302ae71e257eec5 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sat, 2 Dec 2023 10:51:31 +0100 Subject: [PATCH 02/20] First working version of mpi test decorator --- testsuite/pytests/utilities/mpi_wrapper.py | 39 ++++++++----------- .../pytests/utilities/test_brunel2000_mpi.py | 22 +++++------ 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/testsuite/pytests/utilities/mpi_wrapper.py b/testsuite/pytests/utilities/mpi_wrapper.py index 7a47ae0a66..3fdea52dc3 100644 --- a/testsuite/pytests/utilities/mpi_wrapper.py +++ b/testsuite/pytests/utilities/mpi_wrapper.py @@ -29,6 +29,7 @@ from pathlib import Path import numpy as np +import pandas as pd import pytest from decorator import decorator @@ -50,9 +51,8 @@ def _parse_import_statements(self, caller_fname): modules = [] for node in ast.walk(tree): - if isinstance(node, (ast.Import, ast.ImportFrom)): + if isinstance(node, (ast.Import, ast.ImportFrom)) and not node.names[0].name.startswith("mpi_assert"): modules.append(ast.unparse(node).encode()) - return modules def _parse_func_source(self, func): @@ -103,30 +103,12 @@ def wrapper(func, *args, **kwargs): for procs in self._procs_lst: # TODO: MPI and subprocess does not seem to play well together - """ - subprocess.run(['python', fp.name], - capture_output=True, - check=True - ) - """ - - """ - res = subprocess.run( - ["mpirun", "-np", str(procs), "python", fp.name], capture_output=True, check=True, env=os.environ - ) - - """ - res = subprocess.run( - # ["mpiexec", "-n", str(procs), sys.executable, fp.name], - ["mpirun", "-np", str(procs), "python", fp.name], - capture_output=True, - check=True, - shell=True, - env=os.environ, - ) + res = subprocess.run(["mpirun", "-np", str(procs), "python", "runner.py"], check=True, cwd=self._path) print(res) + self._check_equal() + # Here we need to assert that dfs from all runs are equal return decorator(wrapper, func) @@ -154,3 +136,14 @@ def _write_runner(self, fp, func, *args, **kwargs): # TODO: only the main block needs to be changed between runner runs fp.write(self._main_block_equal_df(func, *args, **kwargs)) + + def _check_equal(self): + res = [ + pd.concat(pd.read_csv(f, sep="\t", comment="#") for f in self._path.glob(f"sr_{n:02d}*.dat")).sort_values( + by=["time_ms", "sender"] + ) + for n in self._procs_lst + ] + + for r in res[1:]: + pd.testing.assert_frame_equal(res[0], r) diff --git a/testsuite/pytests/utilities/test_brunel2000_mpi.py b/testsuite/pytests/utilities/test_brunel2000_mpi.py index b694eba0af..a4fede3aa1 100644 --- a/testsuite/pytests/utilities/test_brunel2000_mpi.py +++ b/testsuite/pytests/utilities/test_brunel2000_mpi.py @@ -19,13 +19,8 @@ # You should have received a copy of the GNU General Public License # along with NEST. If not, see . -import nest -import numpy as np -import pandas as pd -import pytest -from mpi_wrapper import mpi_assert_equal_df -# from mpi4py import MPI +from mpi_wrapper import mpi_assert_equal_df @mpi_assert_equal_df([1, 2]) @@ -40,14 +35,16 @@ def test_brunel2000(): 183-208 (2000). """ + import nest + nest.ResetKernel() nest.set(total_num_virtual_procs=2) # Model parameters - NE = 10_000 # number of excitatory neurons - NI = 2_500 # number of inhibitory neurons - CE = 1_000 # number of excitatory synapses per neuron + NE = 1000 # number of excitatory neurons + NI = 250 # number of inhibitory neurons + CE = 100 # number of excitatory synapses per neuron CI = 250 # number of inhibitory synapses per neuron N_rec = 10 # number of (excitatory) neurons to record D = 1.5 # synaptic delay, all connections [ms] @@ -76,7 +73,7 @@ def test_brunel2000(): enodes = nest.Create("iaf_psc_delta", NE, params=neuron_params) inodes = nest.Create("iaf_psc_delta", NI, params=neuron_params) ext = nest.Create("poisson_generator", 1, params={"rate": nu_ext * CE * 1000.0}) - srec = nest.Create("spike_recorder", 1) + srec = nest.Create("spike_recorder", 1, params={"label": f"sr_{nest.num_processes:02d}", "record_to": "ascii"}) nest.CopyModel("static_synapse", "esyn", params={"weight": JE, "delay": D}) nest.CopyModel("static_synapse", "isyn", params={"weight": JI, "delay": D}) @@ -98,6 +95,7 @@ def test_brunel2000(): ) # Simulate network - nest.Simulate(200.0) + nest.Simulate(400) - return pd.DataFrame.from_records(srec.events) + # next variant is for testing the test + # nest.Simulate(200 if nest.num_processes == 1 else 400) From a314bcbe639b50a9bda0881dbfbcb307e1e6fbc8 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 15:04:49 +0100 Subject: [PATCH 03/20] Remove no longer pertinent file from development --- testsuite/pytests/utilities/test_mpi_dev.py | 80 --------------------- 1 file changed, 80 deletions(-) delete mode 100644 testsuite/pytests/utilities/test_mpi_dev.py diff --git a/testsuite/pytests/utilities/test_mpi_dev.py b/testsuite/pytests/utilities/test_mpi_dev.py deleted file mode 100644 index a1190e32cd..0000000000 --- a/testsuite/pytests/utilities/test_mpi_dev.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -# -# test_mpi_dev.py -# -# This file is part of NEST. -# -# Copyright (C) 2004 The NEST Initiative -# -# NEST is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# NEST is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with NEST. If not, see . - - -# TODO: delete this development file - -# import nest -import pandas as pd -import pytest -from mpi_wrapper import mpi_assert_equal_df -from mpi_wrapper2 import mpi_wrapper - -""" -@pytest.mark.parametrize("n_nrns", [2]) -@mpi_assert_equal_df([1, 2]) -def test_func(n_nrns): - nest.ResetKernel() - nest.total_num_virtual_procs = 2 - nrns = nest.Create("iaf_psc_alpha", n_nrns) - sinj = nest.Create("spike_train_injector", params={"spike_times": [1.0, 3.0, 5.0]}) - srec = nest.Create("spike_recorder") - - nest.Connect(sinj, nrns, syn_spec={"weight": 2000.0}) - nest.Connect(nrns, srec) - - nest.Simulate(10.0) - - df = pd.DataFrame.from_records(srec.events) - - return df - - - -# @pytest.mark.parametrize("n_nrns", [2]) -@mpi_wrapper -def test_func(): - n_nrns = 2 - nest.ResetKernel() - nest.total_num_virtual_procs = 2 - nrns = nest.Create("iaf_psc_alpha", n_nrns) - sinj = nest.Create("spike_train_injector", params={"spike_times": [1.0, 3.0, 5.0]}) - srec = nest.Create("spike_recorder") - - nest.Connect(sinj, nrns, syn_spec={"weight": 2000.0}) - nest.Connect(nrns, srec) - - nest.Simulate(10.0) - - df = pd.DataFrame.from_records(srec.events) - - return df -""" - - -# @mpi_wrapper -@mpi_assert_equal_df([1]) -def test_func2(): - a = 2 - b = 4 - # print("I AM TEST") - - return a + b From 75fd473cb1afec6e5e3c6b3caa7aa12a4088cc06 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 19:56:05 +0100 Subject: [PATCH 04/20] Tidy up mpi_wrapper for tests --- testsuite/pytests/utilities/mpi_wrapper.py | 123 ++++++------------ .../pytests/utilities/test_brunel2000_mpi.py | 10 +- 2 files changed, 48 insertions(+), 85 deletions(-) diff --git a/testsuite/pytests/utilities/mpi_wrapper.py b/testsuite/pytests/utilities/mpi_wrapper.py index 3fdea52dc3..c45647a570 100644 --- a/testsuite/pytests/utilities/mpi_wrapper.py +++ b/testsuite/pytests/utilities/mpi_wrapper.py @@ -26,6 +26,7 @@ import subprocess import sys import tempfile +import textwrap from pathlib import Path import numpy as np @@ -33,113 +34,75 @@ import pytest from decorator import decorator -# from mpi4py import MPI - -class DecoratorParserBase: +class MPIWrapper: """ Base class that parses the test module to retrieve imports, test code and test parametrization. """ - def __init__(self): - pass - - def _parse_import_statements(self, caller_fname): - with open(caller_fname, "r") as f: - tree = ast.parse(f.read(), caller_fname) + def __init__(self, procs_lst): + try: + iter(procs_lst) + except TypeError: + raise TypeError("procs_lst must be a list of numbers") - modules = [] - for node in ast.walk(tree): - if isinstance(node, (ast.Import, ast.ImportFrom)) and not node.names[0].name.startswith("mpi_assert"): - modules.append(ast.unparse(node).encode()) - return modules + self._procs_lst = procs_lst + self._caller_fname = inspect.stack()[1].filename + self._tmpdir = None def _parse_func_source(self, func): - lines, _ = inspect.getsourcelines(func) - # remove decorators and encode - func_src = [line.encode() for line in lines if not line.startswith("@")] + func_src = (line.encode() for line in inspect.getsourcelines(func)[0] if not line.startswith("@")) return func_src def _params_as_str(self, *args, **kwargs): - params = "" - if args: - params += ", ".join(f"{arg}" for arg in args) - if kwargs: - if args: - params += ", " - params += ", ".join(f"{key}={value}" for key, value in kwargs.items()) - return params - + return ", ".join( + part + for part in ( + ", ".join(f"{arg}" for arg in args), + ", ".join(f"{key}={value}" for key, value in kwargs.items()), + ) + if part + ) -class mpi_assert_equal_df(DecoratorParserBase): - """ - docs - """ + def _main_block(self, func, *args, **kwargs): + main_block = f""" + if __name__ == '__main__': + {func.__name__}({self._params_as_str(*args, **kwargs)}) + """ - def __init__(self, procs_lst): - if not isinstance(procs_lst, list): - # TODO: Instead of passing the number of MPI procs to run explicitly, i.e., [1, 2, 4], another - # option is to pass the max number, e.g., 4, and handle the range internally - msg = "'mpi_assert_equal_df' decorator requires the number of MPI procs to test to be passed as a list" - raise TypeError(msg) + return textwrap.dedent(main_block).encode() - # TODO: check if len(procs_lst) >= 2 (not yet implemented for debugging purposes) - - self._procs_lst = procs_lst - self._caller_fname = inspect.stack()[1].filename + def _write_runner(self, func, *args, **kwargs): + with open(self._tmpdir / "runner.py", "wb") as fp: + fp.write(b"".join(self._parse_func_source(func))) + fp.write(self._main_block(func, *args, **kwargs)) def __call__(self, func): def wrapper(func, *args, **kwargs): - # TODO: replace path setup below with the following: - # with tempfile.TemporaryDirectory() as tmpdir: - self._path = Path("./tmpdir") - self._path.mkdir(parents=True, exist_ok=True) - - # Write the relevant code from test module to a new, temporary (TODO) - # runner script - with open(self._path / "runner.py", "wb") as fp: - self._write_runner(fp, func, *args, **kwargs) - - for procs in self._procs_lst: - # TODO: MPI and subprocess does not seem to play well together - res = subprocess.run(["mpirun", "-np", str(procs), "python", "runner.py"], check=True, cwd=self._path) - - print(res) + with tempfile.TemporaryDirectory() as tmpdirname: + self._tmpdir = Path(tmpdirname) + self._write_runner(func, *args, **kwargs) - self._check_equal() + for procs in self._procs_lst: + subprocess.run(["mpirun", "-np", str(procs), "python", "runner.py"], check=True, cwd=self._tmpdir) - # Here we need to assert that dfs from all runs are equal + self.assert_correct_results() return decorator(wrapper, func) - def _main_block_equal_df(self, func, *args, **kwargs): - main_block = "\n" - main_block += "if __name__ == '__main__':" - main_block += "\n\t" - main_block += f"df = {func.__name__}({self._params_as_str(*args, **kwargs)})" - main_block += "\n\t" + def assert_correct_results(): + assert False, "Test-specific checks not implemented" - main_block += f"path = '{self._path}'" - main_block += "\n\t" - # Write output df to csv (will be compared later) - # main_block += "df.to_csv(f'{path}/df_{nest.NumProcesses()}-{nest.Rank()}.csv', index=False)" - - return main_block.encode() - - def _write_runner(self, fp, func, *args, **kwargs): - # TODO: most of this can probably be in base class - fp.write(b"\n".join(self._parse_import_statements(self._caller_fname))) - fp.write(b"\n\n") - fp.write(b"".join(self._parse_func_source(func))) - - # TODO: only the main block needs to be changed between runner runs - fp.write(self._main_block_equal_df(func, *args, **kwargs)) +class MPIAssertEqual(MPIWrapper): + """ + Assert that combined, sorted output from all VPs is identical for all numbers of MPI ranks. + """ - def _check_equal(self): + def assert_correct_results(self): res = [ - pd.concat(pd.read_csv(f, sep="\t", comment="#") for f in self._path.glob(f"sr_{n:02d}*.dat")).sort_values( + pd.concat(pd.read_csv(f, sep="\t", comment="#") for f in self._tmpdir.glob(f"sr_{n:02d}*.dat")).sort_values( by=["time_ms", "sender"] ) for n in self._procs_lst diff --git a/testsuite/pytests/utilities/test_brunel2000_mpi.py b/testsuite/pytests/utilities/test_brunel2000_mpi.py index a4fede3aa1..a33bccfee3 100644 --- a/testsuite/pytests/utilities/test_brunel2000_mpi.py +++ b/testsuite/pytests/utilities/test_brunel2000_mpi.py @@ -20,13 +20,13 @@ # along with NEST. If not, see . -from mpi_wrapper import mpi_assert_equal_df +from mpi_wrapper import MPIAssertEqual -@mpi_assert_equal_df([1, 2]) +@MPIAssertEqual([1, 2, 4]) def test_brunel2000(): - """Implementation of the sparsely connected recurrent network described by - Brunel (2000). + """ + Implementation of the sparsely connected recurrent network described by Brunel (2000). References ---------- @@ -39,7 +39,7 @@ def test_brunel2000(): nest.ResetKernel() - nest.set(total_num_virtual_procs=2) + nest.set(total_num_virtual_procs=4, overwrite_files=True) # Model parameters NE = 1000 # number of excitatory neurons From cc91a33dbace033d3902780e1ccd2fd195a1f0d2 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 19:57:08 +0100 Subject: [PATCH 05/20] Remove non-working approach --- testsuite/pytests/utilities/mpi_wrapper2.py | 66 --------------------- 1 file changed, 66 deletions(-) delete mode 100644 testsuite/pytests/utilities/mpi_wrapper2.py diff --git a/testsuite/pytests/utilities/mpi_wrapper2.py b/testsuite/pytests/utilities/mpi_wrapper2.py deleted file mode 100644 index ecf358b6be..0000000000 --- a/testsuite/pytests/utilities/mpi_wrapper2.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -# -# mpi_wrapper2.py -# -# This file is part of NEST. -# -# Copyright (C) 2004 The NEST Initiative -# -# NEST is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# NEST is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with NEST. If not, see . - -import inspect -import os -import subprocess -from functools import wraps - - -# NOTE: This approach doesn't seem to work -def mpi_wrapper(func): - @wraps(func) - def wrapper(*args, **kwargs): - num_processes = 1 - - file = inspect.getfile(func) - module = func.__module__ - path = os.path.abspath(file) - - print("module.count('.'):", module.count(".")) - - for _ in range(module.count(".")): - print("path =", path) - path = os.path.split(path)[0] - - command = [ - "mpiexec", - "-n", - str(num_processes), - "-wdir", - path, - "python", - "-c", - f"from {module} import *; {func.__name__}()", - ] - - # f"import {module} as module; module.{func.__name__}()", - - print(func.__name__) - # print(module.func.__name__.original()) - - subprocess.run(command, capture_output=True, check=True, env=os.environ) - - print("args:", args, "kwargs:", kwargs) - - # return func(*args, **kwargs) - - return wrapper From d2121d2b7bfcff4c99be0faf5384371a01c32775 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 20:34:41 +0100 Subject: [PATCH 06/20] Move files for python-based mpi tests to proper place --- testsuite/do_tests.sh | 10 +- testsuite/mpitests/test_mini_brunel_ps.sli | 195 ------------------ testsuite/pytests/sli2py_mpi/README.md | 13 ++ .../{utilities => sli2py_mpi}/mpi_wrapper.py | 0 .../test_brunel2000_mpi.py | 4 +- 5 files changed, 24 insertions(+), 198 deletions(-) delete mode 100644 testsuite/mpitests/test_mini_brunel_ps.sli create mode 100644 testsuite/pytests/sli2py_mpi/README.md rename testsuite/pytests/{utilities => sli2py_mpi}/mpi_wrapper.py (100%) rename testsuite/pytests/{utilities => sli2py_mpi}/test_brunel2000_mpi.py (96%) diff --git a/testsuite/do_tests.sh b/testsuite/do_tests.sh index 640570bbc4..ddcf320689 100755 --- a/testsuite/do_tests.sh +++ b/testsuite/do_tests.sh @@ -508,7 +508,15 @@ if test "${PYTHON}"; then env set +e "${PYTHON}" -m pytest --verbose --timeout $TIME_LIMIT --junit-xml="${XUNIT_FILE}" --numprocesses=1 \ - --ignore="${PYNEST_TEST_DIR}/mpi" "${PYNEST_TEST_DIR}" 2>&1 | tee -a "${TEST_LOGFILE}" + --ignore="${PYNEST_TEST_DIR}/mpi" --ignore="${PYNEST_TEST_DIR}/sli2py_mpi" "${PYNEST_TEST_DIR}" 2>&1 | tee -a "${TEST_LOGFILE}" + set -e + + # Run tests in the sli2py_mpi subdirectory. The must be run without loading conftest.py. + XUNIT_FILE="${REPORTDIR}/${XUNIT_NAME}_sli2py_mpi.xml" + env + set +e + "${PYTHON}" -m pytest --noconftest --verbose --timeout $TIME_LIMIT --junit-xml="${XUNIT_FILE}" --numprocesses=1 \ + "${PYNEST_TEST_DIR}/sli2py_mpi" 2>&1 | tee -a "${TEST_LOGFILE}" set -e # Run tests in the mpi* subdirectories, grouped by number of processes diff --git a/testsuite/mpitests/test_mini_brunel_ps.sli b/testsuite/mpitests/test_mini_brunel_ps.sli deleted file mode 100644 index 7b7b969f65..0000000000 --- a/testsuite/mpitests/test_mini_brunel_ps.sli +++ /dev/null @@ -1,195 +0,0 @@ -/* - * test_mini_brunel_ps.sli - * - * This file is part of NEST. - * - * Copyright (C) 2004 The NEST Initiative - * - * NEST is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * NEST is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NEST. If not, see . - * - */ - - /** @BeginDocumentation -Name: testsuite::test_mini_brunel_ps - Test parallel simulation of small Brunel-style network - -Synopsis: nest_indirect test_mini_brunel_ps.sli -> - - -Description: - Simulates scaled-down Brunel net with precise timing for different numbers of MPI - processes and compares results. - -Author: May 2012, Plesser, based on brunel_ps.sli - -See: brunel_ps.sli -*/ - -(unittest) run -/unittest using -/distributed_process_invariant_events_assert_or_die << /show_results true >> SetOptions - -skip_if_not_threaded - -/brunel_setup { -/brunel << >> def - -brunel begin -/order 50 def % scales size of network (total 5*order neurons) - -/g 250.0 def % rel strength, inhibitory synapses -/eta 2.0 def % nu_ext / nu_thresh - -/simtime 200.0 def % simulation time [ms] -/dt 0.1 def % simulation step length [ms] - -% Number of POSIX threads per program instance. -% When using MPI, the mpirun call determines the number -% of MPI processes (=program instances). The total number -% of virtual processes is #MPI processes x local_num_threads. -/total_num_virtual_procs 4 def - -% Compute the maximum of postsynaptic potential -% for a synaptic input current of unit amplitude -% (1 pA) -/ComputePSPnorm -{ - % calculate the normalization factor for the PSP - ( - a = tauMem / tauSyn; - b = 1.0 / tauSyn - 1.0 / tauMem; - % time of maximum - t_max = 1.0/b * (-LambertWm1(-exp(-1.0/a)/a)-1.0/a); - % maximum of PSP for current of unit amplitude - exp(1.0)/(tauSyn*CMem*b) * ((exp(-t_max/tauMem) - exp(-t_max/tauSyn)) / b - t_max*exp(-t_max/tauSyn)) - ) ExecMath -} -def - -%%% PREPARATION SECTION %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -/NE 4 order mul cvi def % number of excitatory neurons -/NI 1 order mul cvi def % number of inhibitory neurons -/N NI NE add def % total number of neurons - -/epsilon 0.1 def % connectivity -/CE epsilon NE mul cvi def % number of excitatory synapses on neuron -/CI epsilon NI mul cvi def % number of inhibitory synapses on neuron -/C CE CI add def % total number of internal synapses per n. -/Cext CE def % number of external synapses on neuron - -/tauMem 20.0 def % neuron membrane time constant [ms] -/CMem 250.0 def % membrane capacity [pF] -/tauSyn 0.5 def % synaptic time constant [ms] -/tauRef 2.0 def % refractory time [ms] -/E_L 0.0 def % resting potential [mV] -/theta 20.0 def % threshold - - -% amplitude of PSP given 1pA current -ComputePSPnorm /J_max_unit Set - -% synaptic weights, scaled for our alpha functions, such that -% for constant membrane potential, the peak amplitude of the PSP -% equals J - -/delay 1.5 def % synaptic delay, all connections [ms] -/J 0.1 def % synaptic weight [mV] -/JE J J_max_unit div def % synaptic weight [pA] -/JI g JE mul neg def % inhibitory - -% threshold rate, equivalent rate of events needed to -% have mean input current equal to threshold -/nu_thresh ((theta * CMem) / (JE*CE*exp(1)*tauMem*tauSyn)) ExecMath def -/nu_ext eta nu_thresh mul def % external rate per synapse -/p_rate nu_ext Cext mul 1000. mul def % external input rate per neuron - % must be given in Hz - -% number of neurons to record from -/Nrec 20 def - -end -} -def % brunel_setup - -%%% CONSTRUCTION SECTION %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -[1 2 4] -{ - brunel_setup - /brunel using - % set resolution and total/local number of threads - << - /resolution dt - /total_num_virtual_procs total_num_virtual_procs - >> SetKernelStatus - - /E_neurons /iaf_psc_alpha_ps NE Create def % create excitatory neurons - /I_neurons /iaf_psc_alpha_ps NI Create def % create inhibitory neurons - /allNeurons E_neurons I_neurons join def - - /expoisson /poisson_generator_ps Create def - expoisson - << % set firing rate - /rate p_rate - >> SetStatus - - /inpoisson /poisson_generator_ps Create def - inpoisson - << - /rate p_rate - >> SetStatus - - /exsr /spike_recorder Create def - exsr << /time_in_steps true >> SetStatus - - allNeurons - { - << - /tau_m tauMem - /C_m CMem - /tau_syn_ex tauSyn - /tau_syn_in tauSyn - /t_ref tauRef - /E_L E_L - /V_th theta - /V_m E_L - /V_reset E_L - /C_m 1.0 % capacitance is unity in Brunel model - >> SetStatus - } forall - - /static_synapse << /delay delay >> SetDefaults - /static_synapse /syn_ex << /weight JE >> CopyModel - /static_synapse /syn_in << /weight JI >> CopyModel - - expoisson E_neurons stack /all_to_all /syn_ex Connect - E_neurons E_neurons << /rule /fixed_indegree /indegree CE >> << /synapse_model /syn_ex >> Connect - I_neurons E_neurons << /rule /fixed_indegree /indegree CI >> << /synapse_model /syn_in >> Connect - - - inpoisson I_neurons /all_to_all /syn_ex Connect - E_neurons I_neurons << /rule /fixed_indegree /indegree CE >> << /synapse_model /syn_ex >> Connect - I_neurons I_neurons << /rule /fixed_indegree /indegree CI >> << /synapse_model /syn_in >> Connect - - E_neurons Nrec Take exsr Connect - - simtime Simulate - - % get events, replace vectors with SLI arrays - /ev exsr /events get def - ev keys { /k Set ev dup k get cva k exch put } forall - ev - endusing - -} distributed_process_invariant_events_assert_or_die diff --git a/testsuite/pytests/sli2py_mpi/README.md b/testsuite/pytests/sli2py_mpi/README.md new file mode 100644 index 0000000000..74059401c7 --- /dev/null +++ b/testsuite/pytests/sli2py_mpi/README.md @@ -0,0 +1,13 @@ +# MPI tests + +Test in this directory run NEST with different numbers of MPI ranks and compare results. + +- The process is managed by subclasses of class MPIWrapper +- Each test file must contain exactly one test function +- The test function must be decorated with a subclass of MPIWrapper +- Test files **must not import nest** outside the test function +- conftest.py must not be loaded, otherwise mpirun will return a non-zero exit code; use pytest --noconftest +- The wrapper will write a modified version of the test file to a temporary directory and mpirun it from there; results are collected in the temporary directory +- Evaluation criteria are determined by the MPIWrapper subclass + +This is still work in progress. diff --git a/testsuite/pytests/utilities/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py similarity index 100% rename from testsuite/pytests/utilities/mpi_wrapper.py rename to testsuite/pytests/sli2py_mpi/mpi_wrapper.py diff --git a/testsuite/pytests/utilities/test_brunel2000_mpi.py b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py similarity index 96% rename from testsuite/pytests/utilities/test_brunel2000_mpi.py rename to testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py index a33bccfee3..e9351f5588 100644 --- a/testsuite/pytests/utilities/test_brunel2000_mpi.py +++ b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py @@ -70,8 +70,8 @@ def test_brunel2000(): nu_ext = eta * nu_thresh # Build network - enodes = nest.Create("iaf_psc_delta", NE, params=neuron_params) - inodes = nest.Create("iaf_psc_delta", NI, params=neuron_params) + enodes = nest.Create("iaf_psc_delta_ps", NE, params=neuron_params) + inodes = nest.Create("iaf_psc_delta_ps", NI, params=neuron_params) ext = nest.Create("poisson_generator", 1, params={"rate": nu_ext * CE * 1000.0}) srec = nest.Create("spike_recorder", 1, params={"label": f"sr_{nest.num_processes:02d}", "record_to": "ascii"}) From 5b11879f50f02c927f2fb6e0e45f8030f2523793 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 21:07:14 +0100 Subject: [PATCH 07/20] Remove unused imports --- testsuite/pytests/sli2py_mpi/mpi_wrapper.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py index c45647a570..05fe76f87f 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py @@ -19,17 +19,12 @@ # You should have received a copy of the GNU General Public License # along with NEST. If not, see . -import ast -import functools import inspect -import os import subprocess -import sys import tempfile import textwrap from pathlib import Path -import numpy as np import pandas as pd import pytest from decorator import decorator From 150af4e921ba879bc902ebf29653dc263966defc Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 21:07:34 +0100 Subject: [PATCH 08/20] Add decorator to requirements for testing --- .github/workflows/nestbuildmatrix.yml | 4 ++-- requirements_testing.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nestbuildmatrix.yml b/.github/workflows/nestbuildmatrix.yml index d2e61be1f7..39de672cbf 100644 --- a/.github/workflows/nestbuildmatrix.yml +++ b/.github/workflows/nestbuildmatrix.yml @@ -618,7 +618,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools python -c "import setuptools; print('package location:', setuptools.__file__)" - python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist cython matplotlib terminaltables pandoc pandas + python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist cython matplotlib terminaltables pandoc pandas decorator # Install mpi4py regardless of whether we compile NEST with or without MPI, so regressiontests/issue-1703.py will run in both cases python -m pip install --force-reinstall --upgrade mpi4py test \! -e "=2" # assert junitparser is correctly quoted and '>' is not interpreted as shell redirect @@ -772,7 +772,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools python -c "import setuptools; print('package location:', setuptools.__file__)" - python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist mpi4py h5py cython matplotlib terminaltables pandoc pandas + python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist mpi4py h5py cython matplotlib terminaltables pandoc pandas decorator test \! -e "=2" # assert junitparser is correctly quoted and '>' is not interpreted as shell redirect python -c "import pytest; print('package location:', pytest.__file__)" pip list diff --git a/requirements_testing.txt b/requirements_testing.txt index a377b7bf54..6203b3d3b1 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -20,6 +20,7 @@ pytest-pylint pytest-mypy pytest-cov data-science-types +decorator terminaltables pycodestyle pydocstyle From 45c4594b60088bfd31635e070406fd152c972ee5 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Fri, 9 Feb 2024 21:42:37 +0100 Subject: [PATCH 09/20] Fix running of sli2py mpitests --- testsuite/do_tests.sh | 15 +++++++++------ testsuite/pytests/sli2py_mpi/mpi_wrapper.py | 6 +++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/testsuite/do_tests.sh b/testsuite/do_tests.sh index ddcf320689..3190457cda 100755 --- a/testsuite/do_tests.sh +++ b/testsuite/do_tests.sh @@ -145,6 +145,7 @@ ls -la "${TEST_BASEDIR}" NEST="nest_serial" HAVE_MPI="$(sli -c 'statusdict/have_mpi :: =only')" +HAVE_OPENMP="$(sli -c 'is_threaded =only')" if test "${HAVE_MPI}" = "true"; then MPI_LAUNCHER="$(sli -c 'statusdict/mpiexec :: =only')" @@ -512,12 +513,14 @@ if test "${PYTHON}"; then set -e # Run tests in the sli2py_mpi subdirectory. The must be run without loading conftest.py. - XUNIT_FILE="${REPORTDIR}/${XUNIT_NAME}_sli2py_mpi.xml" - env - set +e - "${PYTHON}" -m pytest --noconftest --verbose --timeout $TIME_LIMIT --junit-xml="${XUNIT_FILE}" --numprocesses=1 \ - "${PYNEST_TEST_DIR}/sli2py_mpi" 2>&1 | tee -a "${TEST_LOGFILE}" - set -e + if test "${HAVE_MPI}" = "true" && test "${HAVE_OPENMP}" = "true" ; then + XUNIT_FILE="${REPORTDIR}/${XUNIT_NAME}_sli2py_mpi.xml" + env + set +e + "${PYTHON}" -m pytest --noconftest --verbose --timeout $TIME_LIMIT --junit-xml="${XUNIT_FILE}" --numprocesses=1 \ + "${PYNEST_TEST_DIR}/sli2py_mpi" 2>&1 | tee -a "${TEST_LOGFILE}" + set -e + fi # Run tests in the mpi* subdirectories, grouped by number of processes if test "${HAVE_MPI}" = "true"; then diff --git a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py index 05fe76f87f..a2ccd5d6f0 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py @@ -80,7 +80,11 @@ def wrapper(func, *args, **kwargs): self._write_runner(func, *args, **kwargs) for procs in self._procs_lst: - subprocess.run(["mpirun", "-np", str(procs), "python", "runner.py"], check=True, cwd=self._tmpdir) + subprocess.run( + ["mpirun", "-np", str(procs), "--oversubscribe", "python", "runner.py"], + check=True, + cwd=self._tmpdir, + ) self.assert_correct_results() From bcd6055502ee41ebcadd48c2d6b32d419a9a4d9d Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sat, 10 Feb 2024 15:24:11 +0100 Subject: [PATCH 10/20] Add debugging support to MPI test --- testsuite/pytests/sli2py_mpi/mpi_wrapper.py | 14 ++++++++++---- .../pytests/sli2py_mpi/test_brunel2000_mpi.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py index a2ccd5d6f0..96055b9978 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py @@ -36,14 +36,14 @@ class MPIWrapper: test parametrization. """ - def __init__(self, procs_lst): + def __init__(self, procs_lst, debug=False): try: iter(procs_lst) except TypeError: raise TypeError("procs_lst must be a list of numbers") self._procs_lst = procs_lst - self._caller_fname = inspect.stack()[1].filename + self._debug = debug self._tmpdir = None def _parse_func_source(self, func): @@ -75,17 +75,23 @@ def _write_runner(self, func, *args, **kwargs): def __call__(self, func): def wrapper(func, *args, **kwargs): - with tempfile.TemporaryDirectory() as tmpdirname: + with tempfile.TemporaryDirectory(delete=not self._debug) as tmpdirname: self._tmpdir = Path(tmpdirname) self._write_runner(func, *args, **kwargs) + res = {} for procs in self._procs_lst: - subprocess.run( + res[procs] = subprocess.run( ["mpirun", "-np", str(procs), "--oversubscribe", "python", "runner.py"], check=True, cwd=self._tmpdir, + capture_output=self._debug, ) + if self._debug: + print(f"\n\nTMPDIR: {self._tmpdir}\n\n") + print(res) + self.assert_correct_results() return decorator(wrapper, func) diff --git a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py index e9351f5588..b27c7e2fc0 100644 --- a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py +++ b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py @@ -23,7 +23,7 @@ from mpi_wrapper import MPIAssertEqual -@MPIAssertEqual([1, 2, 4]) +@MPIAssertEqual([1, 2, 4], debug=False) def test_brunel2000(): """ Implementation of the sparsely connected recurrent network described by Brunel (2000). From 5ab0f453e7803360f7ae07ef52e4568e5b7ecb4b Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sat, 10 Feb 2024 23:00:05 +0100 Subject: [PATCH 11/20] Fix debug mode for Py < 3.12 --- testsuite/pytests/sli2py_mpi/mpi_wrapper.py | 7 ++++++- testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py index 96055b9978..9342bd5ff2 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py @@ -75,7 +75,12 @@ def _write_runner(self, func, *args, **kwargs): def __call__(self, func): def wrapper(func, *args, **kwargs): - with tempfile.TemporaryDirectory(delete=not self._debug) as tmpdirname: + try: + tmpdir = tempfile.TemporaryDirectory(delete=not self._debug) + except TypeError: + # delete parameter only available in Python 3.12 and later + tmpdir = tempfile.TemporaryDirectory() + with tmpdir as tmpdirname: self._tmpdir = Path(tmpdirname) self._write_runner(func, *args, **kwargs) diff --git a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py index b27c7e2fc0..ad4976935d 100644 --- a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py +++ b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py @@ -23,7 +23,7 @@ from mpi_wrapper import MPIAssertEqual -@MPIAssertEqual([1, 2, 4], debug=False) +@MPIAssertEqual([1, 2, 4]) def test_brunel2000(): """ Implementation of the sparsely connected recurrent network described by Brunel (2000). @@ -97,5 +97,5 @@ def test_brunel2000(): # Simulate network nest.Simulate(400) - # next variant is for testing the test + # Uncomment next line to provoke test failure # nest.Simulate(200 if nest.num_processes == 1 else 400) From ea17b48e7da159eb374686d809fdb7989ad7d5c3 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sun, 11 Feb 2024 14:24:51 +0100 Subject: [PATCH 12/20] Improve MPI test setup further --- .../pytests/sli2py_mpi/mpi_test_wrapper.py | 142 ++++++++++++++++++ testsuite/pytests/sli2py_mpi/mpi_wrapper.py | 122 --------------- .../pytests/sli2py_mpi/test_brunel2000_mpi.py | 12 +- 3 files changed, 150 insertions(+), 126 deletions(-) create mode 100644 testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py delete mode 100644 testsuite/pytests/sli2py_mpi/mpi_wrapper.py diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py new file mode 100644 index 0000000000..5ea229de65 --- /dev/null +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# +# mpi_test_wrapper.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + +import inspect +import subprocess +import tempfile +import textwrap +from pathlib import Path + +import pandas as pd +import pytest +from decorator import decorator + + +class MPITestWrapper: + """- + Base class that parses the test module to retrieve imports, test code and + test parametrization. + """ + + RUNNER = "runner.py" + SPIKE_LABEL = "spikes-{}" + + RUNNER_TEMPLATE = textwrap.dedent( + """\ + SPIKE_LABEL = '{spike_lbl}' + + {fcode} + + if __name__ == '__main__': + {fname}({params}) + """ + ) + + def __init__(self, procs_lst, debug=False): + try: + iter(procs_lst) + except TypeError: + raise TypeError("procs_lst must be a list of numbers") + + self._procs_lst = procs_lst + self._debug = debug + + def _func_without_decorators(self, func): + return "".join(line for line in inspect.getsourcelines(func)[0] if not line.startswith("@")) + + def _params_as_str(self, *args, **kwargs): + return ", ".join( + part + for part in ( + ", ".join(f"{arg}" for arg in args), + ", ".join(f"{key}={value}" for key, value in kwargs.items()), + ) + if part + ) + + def _write_runner(self, tmpdirpath, func, *args, **kwargs): + with open(tmpdirpath / self.RUNNER, "w") as fp: + fp.write( + self.RUNNER_TEMPLATE.format( + spike_lbl=self.SPIKE_LABEL, + fcode=self._func_without_decorators(func), + fname=func.__name__, + params=self._params_as_str(*args, **kwargs), + ) + ) + + def __call__(self, func): + def wrapper(func, *args, **kwargs): + # "delete" parameter only available in Python 3.12 and later + try: + tmpdir = tempfile.TemporaryDirectory(delete=not self._debug) + except TypeError: + tmpdir = tempfile.TemporaryDirectory() + + # TemporaryDirectory() is not os.PathLike, so we need to define a Path explicitly + # To ensure that tmpdirpath has the same lifetime as tmpdir, we define it as a local + # variable in the wrapper() instead of as an attribute of the decorator. + tmpdirpath = Path(tmpdir.name) + self._write_runner(tmpdirpath, func, *args, **kwargs) + + res = {} + for procs in self._procs_lst: + res[procs] = subprocess.run( + ["mpirun", "-np", str(procs), "--oversubscribe", "python", self.RUNNER], + check=True, + cwd=tmpdirpath, + capture_output=self._debug, + ) + + if self._debug: + print(f"\n\nTMPDIR: {tmpdirpath}\n\n") + print(res) + + self.collect_results(tmpdirpath) + self.assert_correct_results() + + return decorator(wrapper, func) + + def collect_results(self, tmpdirpath): + self._spikes = { + n_procs: [ + pd.read_csv(f, sep="\t", comment="#") + for f in tmpdirpath.glob(f"{self.SPIKE_LABEL.format(n_procs)}-*.dat") + ] + for n_procs in self._procs_lst + } + + def assert_correct_results(self): + assert False, "Test-specific checks not implemented" + + +class MPITestAssertEqual(MPITestWrapper): + """ + Assert that combined, sorted output from all VPs is identical for all numbers of MPI ranks. + """ + + def assert_correct_results(self): + res = [ + pd.concat(spikes).sort_values(by=["time_step", "time_offset", "sender"]) for spikes in self._spikes.values() + ] + + for r in res[1:]: + pd.testing.assert_frame_equal(res[0], r) diff --git a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_wrapper.py deleted file mode 100644 index 9342bd5ff2..0000000000 --- a/testsuite/pytests/sli2py_mpi/mpi_wrapper.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- -# -# mpi_wrapper.py -# -# This file is part of NEST. -# -# Copyright (C) 2004 The NEST Initiative -# -# NEST is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# NEST is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with NEST. If not, see . - -import inspect -import subprocess -import tempfile -import textwrap -from pathlib import Path - -import pandas as pd -import pytest -from decorator import decorator - - -class MPIWrapper: - """ - Base class that parses the test module to retrieve imports, test code and - test parametrization. - """ - - def __init__(self, procs_lst, debug=False): - try: - iter(procs_lst) - except TypeError: - raise TypeError("procs_lst must be a list of numbers") - - self._procs_lst = procs_lst - self._debug = debug - self._tmpdir = None - - def _parse_func_source(self, func): - func_src = (line.encode() for line in inspect.getsourcelines(func)[0] if not line.startswith("@")) - return func_src - - def _params_as_str(self, *args, **kwargs): - return ", ".join( - part - for part in ( - ", ".join(f"{arg}" for arg in args), - ", ".join(f"{key}={value}" for key, value in kwargs.items()), - ) - if part - ) - - def _main_block(self, func, *args, **kwargs): - main_block = f""" - if __name__ == '__main__': - {func.__name__}({self._params_as_str(*args, **kwargs)}) - """ - - return textwrap.dedent(main_block).encode() - - def _write_runner(self, func, *args, **kwargs): - with open(self._tmpdir / "runner.py", "wb") as fp: - fp.write(b"".join(self._parse_func_source(func))) - fp.write(self._main_block(func, *args, **kwargs)) - - def __call__(self, func): - def wrapper(func, *args, **kwargs): - try: - tmpdir = tempfile.TemporaryDirectory(delete=not self._debug) - except TypeError: - # delete parameter only available in Python 3.12 and later - tmpdir = tempfile.TemporaryDirectory() - with tmpdir as tmpdirname: - self._tmpdir = Path(tmpdirname) - self._write_runner(func, *args, **kwargs) - - res = {} - for procs in self._procs_lst: - res[procs] = subprocess.run( - ["mpirun", "-np", str(procs), "--oversubscribe", "python", "runner.py"], - check=True, - cwd=self._tmpdir, - capture_output=self._debug, - ) - - if self._debug: - print(f"\n\nTMPDIR: {self._tmpdir}\n\n") - print(res) - - self.assert_correct_results() - - return decorator(wrapper, func) - - def assert_correct_results(): - assert False, "Test-specific checks not implemented" - - -class MPIAssertEqual(MPIWrapper): - """ - Assert that combined, sorted output from all VPs is identical for all numbers of MPI ranks. - """ - - def assert_correct_results(self): - res = [ - pd.concat(pd.read_csv(f, sep="\t", comment="#") for f in self._tmpdir.glob(f"sr_{n:02d}*.dat")).sort_values( - by=["time_ms", "sender"] - ) - for n in self._procs_lst - ] - - for r in res[1:]: - pd.testing.assert_frame_equal(res[0], r) diff --git a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py index ad4976935d..eb1993e5b5 100644 --- a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py +++ b/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py @@ -20,10 +20,10 @@ # along with NEST. If not, see . -from mpi_wrapper import MPIAssertEqual +from mpi_test_wrapper import MPITestAssertEqual -@MPIAssertEqual([1, 2, 4]) +@MPITestAssertEqual([1, 2, 4], debug=False) def test_brunel2000(): """ Implementation of the sparsely connected recurrent network described by Brunel (2000). @@ -42,7 +42,7 @@ def test_brunel2000(): nest.set(total_num_virtual_procs=4, overwrite_files=True) # Model parameters - NE = 1000 # number of excitatory neurons + NE = 1000 # number of excitatory neurons- NI = 250 # number of inhibitory neurons CE = 100 # number of excitatory synapses per neuron CI = 250 # number of inhibitory synapses per neuron @@ -73,7 +73,11 @@ def test_brunel2000(): enodes = nest.Create("iaf_psc_delta_ps", NE, params=neuron_params) inodes = nest.Create("iaf_psc_delta_ps", NI, params=neuron_params) ext = nest.Create("poisson_generator", 1, params={"rate": nu_ext * CE * 1000.0}) - srec = nest.Create("spike_recorder", 1, params={"label": f"sr_{nest.num_processes:02d}", "record_to": "ascii"}) + srec = nest.Create( + "spike_recorder", + 1, + params={"label": SPIKE_LABEL.format(nest.num_processes), "record_to": "ascii", "time_in_steps": True}, + ) nest.CopyModel("static_synapse", "esyn", params={"weight": JE, "delay": D}) nest.CopyModel("static_synapse", "isyn", params={"weight": JI, "delay": D}) From 25ce2a62a32ec7acb219118dce487f229ad57416 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sun, 11 Feb 2024 14:58:54 +0100 Subject: [PATCH 13/20] Rename test file and minor touch-ups --- testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py | 3 ++- ..._brunel2000_mpi.py => test_mini_brunel_ps.py} | 16 +++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) rename testsuite/pytests/sli2py_mpi/{test_brunel2000_mpi.py => test_mini_brunel_ps.py} (87%) diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index 5ea229de65..e2b0ea5804 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -107,8 +107,9 @@ def wrapper(func, *args, **kwargs): ) if self._debug: - print(f"\n\nTMPDIR: {tmpdirpath}\n\n") + print("\n\n") print(res) + print(f"\n\nTMPDIR: {tmpdirpath}\n\n") self.collect_results(tmpdirpath) self.assert_correct_results() diff --git a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py similarity index 87% rename from testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py rename to testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py index eb1993e5b5..87ddbd07f1 100644 --- a/testsuite/pytests/sli2py_mpi/test_brunel2000_mpi.py +++ b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# test_brunel2000_mpi.py +# test_mini_brunel_ps.py # # This file is part of NEST. # @@ -24,15 +24,9 @@ @MPITestAssertEqual([1, 2, 4], debug=False) -def test_brunel2000(): +def test_mini_brunel_ps(): """ - Implementation of the sparsely connected recurrent network described by Brunel (2000). - - References - ---------- - Brunel N, Dynamics of Sparsely Connected Networks of Excitatory and - Inhibitory Spiking Neurons, Journal of Computational Neuroscience 8, - 183-208 (2000). + Confirm that downscaled Brunel net with precise neurons is invariant under number of MPI ranks. """ import nest @@ -72,7 +66,7 @@ def test_brunel2000(): # Build network enodes = nest.Create("iaf_psc_delta_ps", NE, params=neuron_params) inodes = nest.Create("iaf_psc_delta_ps", NI, params=neuron_params) - ext = nest.Create("poisson_generator", 1, params={"rate": nu_ext * CE * 1000.0}) + ext = nest.Create("poisson_generator_ps", 1, params={"rate": nu_ext * CE * 1000.0}) srec = nest.Create( "spike_recorder", 1, @@ -102,4 +96,4 @@ def test_brunel2000(): nest.Simulate(400) # Uncomment next line to provoke test failure - # nest.Simulate(200 if nest.num_processes == 1 else 400) + # nest.Simulate(200 if -nest.num_processes == 1 else 400) From 0c7f807c885e1d246e3e140d33718b9cbdd2f559 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sun, 11 Feb 2024 21:00:01 +0100 Subject: [PATCH 14/20] Generalize MPI test setup further and add two more tests --- testsuite/mpitests/issue-1957.sli | 51 ----------- testsuite/mpitests/test_all_to_all.sli | 50 ----------- .../pytests/sli2py_mpi/mpi_test_wrapper.py | 85 +++++++++++++++---- .../pytests/sli2py_mpi/test_all_to_all.py | 41 +++++++++ .../pytests/sli2py_mpi/test_issue_1957.py | 52 ++++++++++++ .../pytests/sli2py_mpi/test_mini_brunel_ps.py | 2 +- 6 files changed, 163 insertions(+), 118 deletions(-) delete mode 100644 testsuite/mpitests/issue-1957.sli delete mode 100644 testsuite/mpitests/test_all_to_all.sli create mode 100644 testsuite/pytests/sli2py_mpi/test_all_to_all.py create mode 100644 testsuite/pytests/sli2py_mpi/test_issue_1957.py diff --git a/testsuite/mpitests/issue-1957.sli b/testsuite/mpitests/issue-1957.sli deleted file mode 100644 index 0f8a4825b4..0000000000 --- a/testsuite/mpitests/issue-1957.sli +++ /dev/null @@ -1,51 +0,0 @@ -/* - * issue-1957.sli - * - * This file is part of NEST. - * - * Copyright (C) 2004 The NEST Initiative - * - * NEST is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * NEST is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NEST. If not, see . - * - */ - - -/** @BeginDocumentation - Name: testsuite::issue-1957 - Test GetConnections after creating and deleting connections - with more ranks than connections - Synopsis: (issue-1957) run -> - - - Description: - issue-1957.sli checks that calling GetConnections after creating and deleting connections - is possible when there are fewer connections than ranks. - - Author: Stine Brekke Vennemo -*/ - -(unittest) run -/unittest using - - -[2 4] -{ - /nrns /iaf_psc_alpha Create def - - nrns nrns /all_to_all Connect - - << >> GetConnections { cva 2 Take } Map /conns Set - - nrns nrns << /rule /all_to_all >> << /synapse_model /static_synapse >> Disconnect_g_g_D_D - << >> GetConnections { cva 2 Take } Map conns join - -} distributed_process_invariant_collect_assert_or_die diff --git a/testsuite/mpitests/test_all_to_all.sli b/testsuite/mpitests/test_all_to_all.sli deleted file mode 100644 index f2a47971ee..0000000000 --- a/testsuite/mpitests/test_all_to_all.sli +++ /dev/null @@ -1,50 +0,0 @@ -/* - * test_all_to_all.sli - * - * This file is part of NEST. - * - * Copyright (C) 2004 The NEST Initiative - * - * NEST is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * NEST is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NEST. If not, see . - * - */ - - -/** @BeginDocumentation - Name: testsuite::test_all_to_all - Test correct connection with many targets - Synopsis: (test_all_to_all) run -> - - - Description: - test_all_to_all.sli checks that all-to-all connections are created - correctly if the number of targets exceeds the number of local nodes. - - Author: Hans Ekkehard Plesser - SeeAlso: testsuite::test_one_to_one, testsuite::test_fixed_indegree, - testsuite::test_pairwise_bernoulli -*/ - -(unittest) run -/unittest using - -% With one MPI process, conventional looping is used. We use that as -% reference case. For 4 processes, we will have fewer local nodes than -% targets and inverse looping is used. -[1 4] -{ - /nrns /iaf_psc_alpha 4 Create def - - nrns nrns /all_to_all Connect - - << >> GetConnections { cva 2 Take } Map -} distributed_process_invariant_collect_assert_or_die diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index e2b0ea5804..872864b02d 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -37,11 +37,15 @@ class MPITestWrapper: """ RUNNER = "runner.py" - SPIKE_LABEL = "spikes-{}" + SPIKE_LABEL = "spike-{}" + MULTI_LABEL = "multi-{}" + OTHER_LABEL = "other-{}" RUNNER_TEMPLATE = textwrap.dedent( """\ SPIKE_LABEL = '{spike_lbl}' + MULTI_LABEL = '{multi_lbl}' + OTHER_LABEL = '{other_lbl}' {fcode} @@ -58,6 +62,9 @@ def __init__(self, procs_lst, debug=False): self._procs_lst = procs_lst self._debug = debug + self._spike = None + self._multi = None + self._other = None def _func_without_decorators(self, func): return "".join(line for line in inspect.getsourcelines(func)[0] if not line.startswith("@")) @@ -77,6 +84,8 @@ def _write_runner(self, tmpdirpath, func, *args, **kwargs): fp.write( self.RUNNER_TEMPLATE.format( spike_lbl=self.SPIKE_LABEL, + multi_lbl=self.MULTI_LABEL, + other_lbl=self.OTHER_LABEL, fcode=self._func_without_decorators(func), fname=func.__name__, params=self._params_as_str(*args, **kwargs), @@ -111,21 +120,32 @@ def wrapper(func, *args, **kwargs): print(res) print(f"\n\nTMPDIR: {tmpdirpath}\n\n") - self.collect_results(tmpdirpath) - self.assert_correct_results() + self.assert_correct_results(tmpdirpath) return decorator(wrapper, func) - def collect_results(self, tmpdirpath): - self._spikes = { - n_procs: [ - pd.read_csv(f, sep="\t", comment="#") - for f in tmpdirpath.glob(f"{self.SPIKE_LABEL.format(n_procs)}-*.dat") - ] + def _collect_result_by_label(self, tmpdirpath, label): + try: + next(tmpdirpath.glob(f"{label.format('*')}.dat")) + except StopIteration: + return None # no data for this label + + return { + n_procs: [pd.read_csv(f, sep="\t", comment="#") for f in tmpdirpath.glob(f"{label.format(n_procs)}-*.dat")] for n_procs in self._procs_lst } - def assert_correct_results(self): + def collect_results(self, tmpdirpath): + """ + For each of the result types, build a dictionary mapping number of MPI procs to a list of + dataframes, collected per rank or VP. + """ + + self._spike = self._collect_result_by_label(tmpdirpath, self.SPIKE_LABEL) + self._multi = self._collect_result_by_label(tmpdirpath, self.MULTI_LABEL) + self._other = self._collect_result_by_label(tmpdirpath, self.OTHER_LABEL) + + def assert_correct_results(self, tmpdirpath): assert False, "Test-specific checks not implemented" @@ -134,10 +154,43 @@ class MPITestAssertEqual(MPITestWrapper): Assert that combined, sorted output from all VPs is identical for all numbers of MPI ranks. """ - def assert_correct_results(self): - res = [ - pd.concat(spikes).sort_values(by=["time_step", "time_offset", "sender"]) for spikes in self._spikes.values() - ] + def assert_correct_results(self, tmpdirpath): + self.collect_results(tmpdirpath) + + all_res = [] + if self._spike: + # For each number of procs, combine results across VPs and sort by time and sender + all_res.append( + [ + pd.concat(spikes, ignore_index=True).sort_values( + by=["time_step", "time_offset", "sender"], ignore_index=True + ) + for spikes in self._spike.values() + ] + ) + + if self._multi: + raise NotImplemented("MULTI is not ready yet") + + if self._other: + # For each number of procs, combine across ranks or VPs (depends on what test has written) and + # sort by all columns so that if results for different proc numbers are equal up to a permutation + # of rows, the sorted frames will compare equal + + # next(iter(...)) returns the first value in the _other dictionary, [0] then picks the first DataFrame from that list + # columns need to be converted to list() to be passed to sort_values() + all_columns = list(next(iter(self._other.values()))[0].columns) + all_res.append( + [ + pd.concat(others, ignore_index=True).sort_values(by=all_columns, ignore_index=True) + for others in self._other.values() + ] + ) + + assert all_res, "No test data collected" + + for res in all_res: + assert len(res) == len(self._procs_lst), "Could not collect data for all procs" - for r in res[1:]: - pd.testing.assert_frame_equal(res[0], r) + for r in res[1:]: + pd.testing.assert_frame_equal(res[0], r) diff --git a/testsuite/pytests/sli2py_mpi/test_all_to_all.py b/testsuite/pytests/sli2py_mpi/test_all_to_all.py new file mode 100644 index 0000000000..e3afacb3d0 --- /dev/null +++ b/testsuite/pytests/sli2py_mpi/test_all_to_all.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# test_all_to_all.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + + +from mpi_test_wrapper import MPITestAssertEqual + + +@MPITestAssertEqual([1, 4], debug=False) +def test_all_to_all(): + """ + Confirm that all-to-all connections created correctly for more targets than local nodes. + """ + + import nest + import pandas as pd + + nest.ResetKernel() + + nrns = nest.Create("parrot_neuron", n=4) + nest.Connect(nrns, nrns, "all_to_all") + + conns = nest.GetConnections().get(output="pandas").drop(labels=["target_thread", "port"], axis=1) + conns.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) diff --git a/testsuite/pytests/sli2py_mpi/test_issue_1957.py b/testsuite/pytests/sli2py_mpi/test_issue_1957.py new file mode 100644 index 0000000000..9c53713abf --- /dev/null +++ b/testsuite/pytests/sli2py_mpi/test_issue_1957.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# test_issue_1957.py +# +# This file is part of NEST. +# +# Copyright (C) 2004 The NEST Initiative +# +# NEST is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# NEST is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with NEST. If not, see . + + +from mpi_test_wrapper import MPITestAssertEqual + + +@MPITestAssertEqual([2, 4]) +def test_issue_1957(): + """ + Confirm that GetConnections works in parallel without hanging if not all ranks have connections. + """ + + import nest + import pandas as pd + + nest.ResetKernel() + + nrn = nest.Create("parrot_neuron") + + # Create two connections so we get lists back from pre_conns.get() and can build a DataFrame + nest.Connect(nrn, nrn) + nest.Connect(nrn, nrn) + + pre_conns = nest.GetConnections() + if pre_conns: + # need to do this here, Disconnect invalidates pre_conns + df = pd.DataFrame.from_dict(pre_conns.get()).drop(labels="target_thread", axis=1) + df.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) + + nest.Disconnect(nrn, nrn) + nest.Disconnect(nrn, nrn) + post_conns = nest.GetConnections() + assert not post_conns diff --git a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py index 87ddbd07f1..e8043d4197 100644 --- a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py +++ b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py @@ -23,7 +23,7 @@ from mpi_test_wrapper import MPITestAssertEqual -@MPITestAssertEqual([1, 2, 4], debug=False) +@MPITestAssertEqual([1, 2, 4]) def test_mini_brunel_ps(): """ Confirm that downscaled Brunel net with precise neurons is invariant under number of MPI ranks. From 4092e64d2e826a64b3b820308793dc458fb78e97 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Sun, 11 Feb 2024 21:20:32 +0100 Subject: [PATCH 15/20] Fix flake8 errors --- testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py | 5 +++-- testsuite/pytests/sli2py_mpi/test_all_to_all.py | 2 +- testsuite/pytests/sli2py_mpi/test_issue_1957.py | 2 +- testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py | 6 +++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index 872864b02d..c2ed2e517e 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -170,14 +170,15 @@ def assert_correct_results(self, tmpdirpath): ) if self._multi: - raise NotImplemented("MULTI is not ready yet") + raise NotImplementedError("MULTI is not ready yet") if self._other: # For each number of procs, combine across ranks or VPs (depends on what test has written) and # sort by all columns so that if results for different proc numbers are equal up to a permutation # of rows, the sorted frames will compare equal - # next(iter(...)) returns the first value in the _other dictionary, [0] then picks the first DataFrame from that list + # next(iter(...)) returns the first value in the _other dictionary + # [0] then picks the first DataFrame from that list # columns need to be converted to list() to be passed to sort_values() all_columns = list(next(iter(self._other.values()))[0].columns) all_res.append( diff --git a/testsuite/pytests/sli2py_mpi/test_all_to_all.py b/testsuite/pytests/sli2py_mpi/test_all_to_all.py index e3afacb3d0..7fdffbfbfc 100644 --- a/testsuite/pytests/sli2py_mpi/test_all_to_all.py +++ b/testsuite/pytests/sli2py_mpi/test_all_to_all.py @@ -38,4 +38,4 @@ def test_all_to_all(): nest.Connect(nrns, nrns, "all_to_all") conns = nest.GetConnections().get(output="pandas").drop(labels=["target_thread", "port"], axis=1) - conns.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) + conns.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) # noqa: F821 diff --git a/testsuite/pytests/sli2py_mpi/test_issue_1957.py b/testsuite/pytests/sli2py_mpi/test_issue_1957.py index 9c53713abf..5b85761443 100644 --- a/testsuite/pytests/sli2py_mpi/test_issue_1957.py +++ b/testsuite/pytests/sli2py_mpi/test_issue_1957.py @@ -44,7 +44,7 @@ def test_issue_1957(): if pre_conns: # need to do this here, Disconnect invalidates pre_conns df = pd.DataFrame.from_dict(pre_conns.get()).drop(labels="target_thread", axis=1) - df.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) + df.to_csv(OTHER_LABEL.format(nest.num_processes) + f"-{nest.Rank()}.dat", index=False) # noqa: F821 nest.Disconnect(nrn, nrn) nest.Disconnect(nrn, nrn) diff --git a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py index e8043d4197..52d09c399f 100644 --- a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py +++ b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py @@ -70,7 +70,11 @@ def test_mini_brunel_ps(): srec = nest.Create( "spike_recorder", 1, - params={"label": SPIKE_LABEL.format(nest.num_processes), "record_to": "ascii", "time_in_steps": True}, + params={ + "label": SPIKE_LABEL.format(nest.num_processes), # noqa: F821 + "record_to": "ascii", + "time_in_steps": True, + }, ) nest.CopyModel("static_synapse", "esyn", params={"weight": JE, "delay": D}) From 2369d96104b3276162567710774225208ac2b2da Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Thu, 15 Feb 2024 16:44:36 +0100 Subject: [PATCH 16/20] Apply suggestions from code review fixing small sloppy errors Co-authored-by: Nicolai Haug <39106781+nicolossus@users.noreply.github.com> --- testsuite/pytests/sli2py_mpi/README.md | 6 +++--- testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py | 2 +- testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/README.md b/testsuite/pytests/sli2py_mpi/README.md index 74059401c7..1cf8d65342 100644 --- a/testsuite/pytests/sli2py_mpi/README.md +++ b/testsuite/pytests/sli2py_mpi/README.md @@ -2,11 +2,11 @@ Test in this directory run NEST with different numbers of MPI ranks and compare results. -- The process is managed by subclasses of class MPIWrapper +- The process is managed by subclasses of the `MPITestWrapper` base class - Each test file must contain exactly one test function -- The test function must be decorated with a subclass of MPIWrapper +- The test function must be decorated with a subclass of `MPITestWrapper` - Test files **must not import nest** outside the test function -- conftest.py must not be loaded, otherwise mpirun will return a non-zero exit code; use pytest --noconftest +- `conftest.py` must not be loaded, otherwise mpirun will return a non-zero exit code; use `pytest --noconftest` - The wrapper will write a modified version of the test file to a temporary directory and mpirun it from there; results are collected in the temporary directory - Evaluation criteria are determined by the MPIWrapper subclass diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index c2ed2e517e..79c8c2674c 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -31,7 +31,7 @@ class MPITestWrapper: - """- + """ Base class that parses the test module to retrieve imports, test code and test parametrization. """ diff --git a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py index 52d09c399f..76ac862665 100644 --- a/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py +++ b/testsuite/pytests/sli2py_mpi/test_mini_brunel_ps.py @@ -36,7 +36,7 @@ def test_mini_brunel_ps(): nest.set(total_num_virtual_procs=4, overwrite_files=True) # Model parameters - NE = 1000 # number of excitatory neurons- + NE = 1000 # number of excitatory neurons NI = 250 # number of inhibitory neurons CE = 100 # number of excitatory synapses per neuron CI = 250 # number of inhibitory synapses per neuron From bb529d610a08aa08687c456f2db00a0240c3c1bc Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Wed, 28 Feb 2024 11:11:53 +0100 Subject: [PATCH 17/20] Remove dependency on external decorator package, use functools.wrap instead Co-authored-by: Dennis Terhorst --- .github/workflows/nestbuildmatrix.yml | 4 ++-- requirements_testing.txt | 1 - testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nestbuildmatrix.yml b/.github/workflows/nestbuildmatrix.yml index 578ebe9e3a..2ebcebe5db 100644 --- a/.github/workflows/nestbuildmatrix.yml +++ b/.github/workflows/nestbuildmatrix.yml @@ -618,7 +618,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools python -c "import setuptools; print('package location:', setuptools.__file__)" - python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist cython matplotlib terminaltables pandoc pandas decorator + python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist cython matplotlib terminaltables pandoc pandas # Install mpi4py regardless of whether we compile NEST with or without MPI, so regressiontests/issue-1703.py will run in both cases python -m pip install --force-reinstall --upgrade mpi4py test \! -e "=2" # assert junitparser is correctly quoted and '>' is not interpreted as shell redirect @@ -772,7 +772,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools python -c "import setuptools; print('package location:', setuptools.__file__)" - python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist mpi4py h5py cython matplotlib terminaltables pandoc pandas decorator + python -m pip install --force-reinstall --upgrade scipy 'junitparser>=2' numpy pytest pytest-timeout pytest-xdist mpi4py h5py cython matplotlib terminaltables pandoc pandas test \! -e "=2" # assert junitparser is correctly quoted and '>' is not interpreted as shell redirect python -c "import pytest; print('package location:', pytest.__file__)" pip list diff --git a/requirements_testing.txt b/requirements_testing.txt index 6203b3d3b1..a377b7bf54 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -20,7 +20,6 @@ pytest-pylint pytest-mypy pytest-cov data-science-types -decorator terminaltables pycodestyle pydocstyle diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index 79c8c2674c..006d6bf4a4 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -24,10 +24,10 @@ import tempfile import textwrap from pathlib import Path +from functools import wraps import pandas as pd import pytest -from decorator import decorator class MPITestWrapper: @@ -93,7 +93,8 @@ def _write_runner(self, tmpdirpath, func, *args, **kwargs): ) def __call__(self, func): - def wrapper(func, *args, **kwargs): + @wraps(func) + def wrapper(*args, **kwargs): # "delete" parameter only available in Python 3.12 and later try: tmpdir = tempfile.TemporaryDirectory(delete=not self._debug) @@ -122,7 +123,7 @@ def wrapper(func, *args, **kwargs): self.assert_correct_results(tmpdirpath) - return decorator(wrapper, func) + return wrapper def _collect_result_by_label(self, tmpdirpath, label): try: From 72dc9a62f206cb36edfd940d100f683bc4cf5f49 Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Wed, 28 Feb 2024 12:51:12 +0100 Subject: [PATCH 18/20] Improve comments --- testsuite/do_tests.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testsuite/do_tests.sh b/testsuite/do_tests.sh index 3190457cda..470a3576af 100755 --- a/testsuite/do_tests.sh +++ b/testsuite/do_tests.sh @@ -504,7 +504,7 @@ if test "${PYTHON}"; then PYNEST_TEST_DIR="${TEST_BASEDIR}/pytests" XUNIT_NAME="07_pynesttests" - # Run all tests except those in the mpi* subdirectories because they cannot be run concurrently + # Run all tests except those in the mpi* and sli2py_mpi subdirectories because they cannot be run concurrently XUNIT_FILE="${REPORTDIR}/${XUNIT_NAME}.xml" env set +e @@ -522,9 +522,10 @@ if test "${PYTHON}"; then set -e fi - # Run tests in the mpi* subdirectories, grouped by number of processes + # Run tests in the mpi/* subdirectories, with one subdirectory per number of processes to use if test "${HAVE_MPI}" = "true"; then if test "${MPI_LAUNCHER}"; then + # Loop over subdirectories whose names are the number of mpi procs to use for numproc in $(cd ${PYNEST_TEST_DIR}/mpi/; ls -d */ | tr -d '/'); do XUNIT_FILE="${REPORTDIR}/${XUNIT_NAME}_mpi_${numproc}.xml" PYTEST_ARGS="--verbose --timeout $TIME_LIMIT --junit-xml=${XUNIT_FILE} ${PYNEST_TEST_DIR}/mpi/${numproc}" From 3d67a2bbfca1f18f5a780048149a4fcb0aa59ded Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Wed, 28 Feb 2024 12:52:54 +0100 Subject: [PATCH 19/20] Filtering of decorators and imports based on AST now --- testsuite/pytests/sli2py_mpi/README.md | 10 +-- .../pytests/sli2py_mpi/mpi_test_wrapper.py | 74 ++++++++++++++++++- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/README.md b/testsuite/pytests/sli2py_mpi/README.md index 1cf8d65342..cb48da01fc 100644 --- a/testsuite/pytests/sli2py_mpi/README.md +++ b/testsuite/pytests/sli2py_mpi/README.md @@ -2,12 +2,4 @@ Test in this directory run NEST with different numbers of MPI ranks and compare results. -- The process is managed by subclasses of the `MPITestWrapper` base class -- Each test file must contain exactly one test function -- The test function must be decorated with a subclass of `MPITestWrapper` -- Test files **must not import nest** outside the test function -- `conftest.py` must not be loaded, otherwise mpirun will return a non-zero exit code; use `pytest --noconftest` -- The wrapper will write a modified version of the test file to a temporary directory and mpirun it from there; results are collected in the temporary directory -- Evaluation criteria are determined by the MPIWrapper subclass - -This is still work in progress. +See documentation in mpi_test_wrappe.py for details. diff --git a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py index 006d6bf4a4..9a76568b2b 100644 --- a/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py +++ b/testsuite/pytests/sli2py_mpi/mpi_test_wrapper.py @@ -19,17 +19,79 @@ # You should have received a copy of the GNU General Public License # along with NEST. If not, see . + +""" +Support for NEST-style MPI Tests. + +NEST-style MPI tests run the same simulation script for different number of MPI +processes and then compare results. Often, the number of virtual processes will +be fixed while the number of MPI processes is varied, but this is not required. + +- The process is managed by subclasses of the `MPITestWrapper` base class +- Each test file must contain exactly one test function + - The test function must be decorated with a subclass of `MPITestWrapper` + - The wrapper will write a modified version of the test file as `runner.py` + to a temporary directory and mpirun it from there; results are collected + in the temporary directory + - The test function can be decorated with other pytest decorators. These + are evaluated in the wrapping process + - No decorators are written to the `runner.py` file. + - Test files **must not import nest** outside the test function + - In `runner.py`, the following constants are defined: + - `SPIKE_LABEL` + - `MULTI_LABEL` + - `OTHER_LABEL` + They must be used as `label` for spike recorders and multimeters, respectively, + or for other files for output data (CSV files). They are format strings expecting + the number of processes with which NEST is run as argument. +- `conftest.py` must not be loaded, otherwise mpirun will return a non-zero exit code; + use `pytest --noconftest` +- Set `debug=True` on the decorator to see debug output and keep the + temporary directory that has been created (latter works only in + Python 3.12 and later) +- Evaluation criteria are determined by the `MPITestWrapper` subclass + +This is still work in progress. +""" + +import ast import inspect import subprocess import tempfile import textwrap -from pathlib import Path from functools import wraps +from pathlib import Path import pandas as pd import pytest +class _RemoveDecoratorsAndMPITestImports(ast.NodeTransformer): + """ + Remove any decorators set on function definitions and imports of MPITest* entities. + + Returning None (falling off the end) of visit_* deletes a node. + See https://docs.python.org/3/library/ast.html#ast.NodeTransformer for details. + + """ + + def visit_FunctionDef(self, node): + """Remove any decorators""" + + node.decorator_list.clear() + return node + + def visit_Import(self, node): + """Drop import""" + if not any(alias.name.startswith("MPITest") for alias in node.names): + return node + + def visit_ImportFrom(self, node): + """Drop from import""" + if not any(alias.name.startswith("MPITest") for alias in node.names): + return node + + class MPITestWrapper: """ Base class that parses the test module to retrieve imports, test code and @@ -66,8 +128,12 @@ def __init__(self, procs_lst, debug=False): self._multi = None self._other = None - def _func_without_decorators(self, func): - return "".join(line for line in inspect.getsourcelines(func)[0] if not line.startswith("@")) + @staticmethod + def _pure_test_func(func): + source_file = inspect.getsourcefile(func) + tree = ast.parse(open(source_file).read()) + _RemoveDecoratorsAndMPITestImports().visit(tree) + return ast.unparse(tree) def _params_as_str(self, *args, **kwargs): return ", ".join( @@ -86,7 +152,7 @@ def _write_runner(self, tmpdirpath, func, *args, **kwargs): spike_lbl=self.SPIKE_LABEL, multi_lbl=self.MULTI_LABEL, other_lbl=self.OTHER_LABEL, - fcode=self._func_without_decorators(func), + fcode=self._pure_test_func(func), fname=func.__name__, params=self._params_as_str(*args, **kwargs), ) From b4518612b648734eb131a234a1198c2ca0c8c57b Mon Sep 17 00:00:00 2001 From: Hans Ekkehard Plesser Date: Wed, 28 Feb 2024 12:53:42 +0100 Subject: [PATCH 20/20] Parametrize test over number of nodes to demonstrate that it is possible. --- testsuite/pytests/sli2py_mpi/test_all_to_all.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/testsuite/pytests/sli2py_mpi/test_all_to_all.py b/testsuite/pytests/sli2py_mpi/test_all_to_all.py index 7fdffbfbfc..e01e07094f 100644 --- a/testsuite/pytests/sli2py_mpi/test_all_to_all.py +++ b/testsuite/pytests/sli2py_mpi/test_all_to_all.py @@ -19,12 +19,16 @@ # You should have received a copy of the GNU General Public License # along with NEST. If not, see . - +import numpy as np +import pandas +import pytest from mpi_test_wrapper import MPITestAssertEqual +# Parametrization over the number of nodes here only so show hat it works +@pytest.mark.parametrize("N", [4, 7]) @MPITestAssertEqual([1, 4], debug=False) -def test_all_to_all(): +def test_all_to_all(N): """ Confirm that all-to-all connections created correctly for more targets than local nodes. """ @@ -34,7 +38,7 @@ def test_all_to_all(): nest.ResetKernel() - nrns = nest.Create("parrot_neuron", n=4) + nrns = nest.Create("parrot_neuron", n=N) nest.Connect(nrns, nrns, "all_to_all") conns = nest.GetConnections().get(output="pandas").drop(labels=["target_thread", "port"], axis=1)