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)