Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/coverage/floors.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"proteus/mprans/MoveMesh.py": 86.4,
"proteus/mprans/MoveMeshMonitor.py": 65.9,
"proteus/mprans/NCLS.py": 75.4,
"proteus/mprans/NCLS3P.py": 76.2,
"proteus/mprans/NCLS3P.py": 75.6,
"proteus/mprans/Pres.py": 76.1,
"proteus/mprans/PresInc.py": 76.2,
"proteus/mprans/PresInit.py": 23.2,
Expand Down
92 changes: 92 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def pytest_configure(config):
"""
import signal
import threading
config.addinivalue_line('markers', 'mumps(reason): needs a PETSc built with MUMPS '
'(skipped without it, except under GitHub Actions)')
if (threading.current_thread() is threading.main_thread()
and signal.getsignal(signal.SIGTERM) == signal.SIG_DFL):
signal.signal(signal.SIGTERM, _end_launches_on_sigterm)
Expand Down Expand Up @@ -80,6 +82,42 @@ def pytest_ignore_collect(collection_path, config):
return None


# MUMPS. Every CI leg's PETSc (conda-forge) has it; a PETSc built without it (the fleet's
# pip-built dev_pip prefix, a hand-built PETSc) cannot run a test that names MUMPS or that needs
# the direct solve it stands for: the singular and saddle-point systems whose fallback,
# `linear_solver: petsc` with PETSc's default GMRES+ILU(0), fails or diverges (a known gap:
# proteus has no scalable no-MUMPS solver for them yet). Such a test carries
# @pytest.mark.mumps(reason) and is skipped there, with the reason in the summary, instead of
# failing 22 times a night. Under GitHub Actions nothing is skipped: a CI environment that lost
# MUMPS must fail, not go quietly green. The probe runs once, in a subprocess (importing petsc4py
# here would start PETSc before a test could give it its own options), and only when a marked test
# is about to run.
_MUMPS = []


def petsc_has_mumps():
"""Whether the PETSc that petsc4py loads was built with MUMPS (probed once per process)."""
if not _MUMPS:
probe = ("import petsc4py; petsc4py.init([]); from petsc4py import PETSc; "
"print(int(PETSc.Sys.hasExternalPackage('mumps')))")
try:
out = subprocess.run([sys.executable, '-c', probe], capture_output=True, text=True,
timeout=120).stdout.strip()
_MUMPS.append(out.endswith('1'))
except (OSError, subprocess.SubprocessError):
_MUMPS.append(True) # cannot tell: run the test and let it say
return _MUMPS[0]


def pytest_runtest_setup(item):
marker = item.get_closest_marker('mumps')
if marker is None or os.environ.get('GITHUB_ACTIONS') == 'true':
return
if not petsc_has_mumps():
why = marker.args[0] if marker.args else marker.kwargs.get('reason', 'it solves with MUMPS')
pytest.skip('this PETSc was built without MUMPS, and ' + why)


def pytest_report_header(config):
lines = []
if _MISSING:
Expand Down Expand Up @@ -471,6 +509,60 @@ def opts():
return runtime.RunOptions()


_DEFAULTS_MODULES = ("proteus.default_p", "proteus.default_n", "proteus.default_so")


def _snapshot_defaults():
"""The namespaces of default_p/default_n/default_so, their dicts and lists copied (a
deck that sets n.femSpaces[0] changes the dict, not the name).

proteus.defaults goes first: when it is first imported it takes its lists of
physics and numerics option names from dir(default_p) and dir(default_n), so
first imported by a deck in the middle of changing them, it would count that
deck's names (p.rdomain) as Proteus options, and Physics_base() would fail
once they were put back. (It imports petsc4py, as the first test module
collected would; Comm.init hands Comm.argv to the options database when PETSc
has already started.)"""
import importlib
import proteus.defaults # noqa: F401
snapshot = {}
for name in _DEFAULTS_MODULES:
namespace = vars(importlib.import_module(name))
snapshot[name] = dict((key, (value.copy() if type(value) in (dict, list) else value))
for key, value in namespace.items())
return snapshot


def _restore_defaults(snapshot):
for name, saved in snapshot.items():
namespace = vars(sys.modules[name])
for key in list(namespace):
if key not in saved:
del namespace[key]
namespace.update(saved)


@pytest.hookimpl(hookwrapper=True)
def pytest_make_collect_report(collector):
"""Put default_p/default_n/default_so back after each test module is imported.

Some test modules build their problem at import, and the decks they import
reload proteus.default_p/default_n and set attributes on them (the BDM and
mass-matrix templates: n.triangleOptions = "Yp", p.nd, ...). Importing is
collecting, which happens before any test and outside the per-test restore
below, so every test file collected after one of them star-imported its
settings as Proteus's defaults: run reversed, poisson_2d, TADR, VOF, NCLS,
vortex and CLSVOF+RANS2P failed. Forward, they were collected first and
passed.
"""
if not isinstance(collector, pytest.Module):
yield
return
snapshot = _snapshot_defaults()
yield
_restore_defaults(snapshot)


@pytest.fixture(autouse=True)
def _restore_proteus_process_state():
"""Put proteus.iproteus.opts, the default_p/n/so modules, proteus.Context,
Expand Down
25 changes: 21 additions & 4 deletions test/multilevel/distmat_mpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
as a LinearAlgebraTools.DistMat, and compares mult and multTranspose on random
vectors, plus a ghosted ParVec_mpi4py against ParVec_petsc4py for the forward
insert and reverse add. Needs petsc4py (it is the reference). Prints the
largest differences.
largest differences, and for the two products a bound on what rounding alone
can make them: k*eps*max_i sum_j |a_ij||x_j|, k the most terms in one sum. Two
evaluations of a k-term sum each lie within (k/2)*eps*sum|a||x| of the exact one
whatever their order or whether a multiply and an add are fused, which a PETSc
built by GCC for aarch64 does (GCC contracts a*b+c into an FMA by default in GNU C
mode; spark2's pip PETSc is built with -g -O). On x86-64 without -mfma it cannot.
The vector scatters only copy and add exactly representable values, so they
must agree exactly.

Usage: distmat_mpi.py NROWS NCOLS
"""
Expand Down Expand Up @@ -41,8 +48,17 @@
pm.assemblyBegin(); pm.assemblyEnd()
dm.assemblyBegin(); dm.assemblyEnd()

x = rng.standard_normal(nCols)[colOff[rank]:colOff[rank + 1]]
y = rng.standard_normal(nRows)[rowOff[rank]:rowOff[rank + 1]]
xAll = rng.standard_normal(nCols)
yAll = rng.standard_normal(nRows)
x = xAll[colOff[rank]:colOff[rank + 1]]
y = yAll[rowOff[rank]:rowOff[rank + 1]]
# what rounding alone can make two evaluations of each product differ by (every rank has every entry)
absA = np.zeros((nRows, nCols))
for (r, cols, vals) in entries:
absA[r, cols] = np.abs(vals)
eps = np.finfo(float).eps
multBound = eps * int((absA > 0).sum(axis=1).max()) * float((absA @ np.abs(xAll)).max())
transBound = eps * int((absA > 0).sum(axis=0).max()) * float((absA.T @ np.abs(yAll)).max())
px, py = pm.createVecRight(), pm.createVecLeft()
dx, dy = dm.createVecRight(), dm.createVecLeft()
px.getArray()[:] = x; dx.getArray()[:] = x
Expand Down Expand Up @@ -71,5 +87,6 @@
revErr = mpi.allreduce(float(np.max(np.abs(arrP[:n] - arrM[:n]), initial=0.0)), op=max)
if rank == 0:
print("RESULT mult=%.3e multTranspose=%.3e forward=%.3e forward_truth=%.3e reverse=%.3e"
% (multErr, transErr, fwdErr, fwdTruth, revErr))
" mult_bound=%.3e multTranspose_bound=%.3e"
% (multErr, transErr, fwdErr, fwdTruth, revErr, multBound, transBound))
sys.stdout.flush()
10 changes: 9 additions & 1 deletion test/multilevel/level_consistent_mpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
owned by another rank, i.e. what PETSc has to communicate.
* ``imbalance`` -- largest owned-node count over the mean, per level.

Usage: level_consistent_mpi.py KIND LEVELS CONSISTENT [node|element] (KIND tri, tet, quad or hex)
Usage: level_consistent_mpi.py KIND LEVELS CONSISTENT [node|element [graph|slabs]] (KIND tri, tet, quad or hex)

With CONSISTENT 0, ``graph`` (the default) cuts every level with the graph partitioner,
a draw that changes from run to run and can nest by luck; ``slabs`` pins each level to
slabs across an axis that turns with the level (pinned_partitions.py), which do not nest.

With element partitioning ``missing`` counts the fine elements this rank owns whose
parent element is not in the coarse subdomain, and ``offproc`` is not measured.
Expand All @@ -35,7 +39,11 @@
nLevels = int(sys.argv[2])
consistent = bool(int(sys.argv[3]))
by = sys.argv[4] if len(sys.argv) > 4 else 'node'
independentOwners = sys.argv[5] if len(sys.argv) > 5 else 'graph'
MeshTools.MultilevelMesh.levelConsistentPartitioning = consistent
if not consistent and independentOwners == 'slabs':
from pinned_partitions import pin_independent_levels
pin_independent_levels(2 if kind in ('tri', 'quad') else 3)
part = (MeshTools.MeshParallelPartitioningTypes.node if by == 'node'
else MeshTools.MeshParallelPartitioningTypes.element)
if kind == 'tri':
Expand Down
82 changes: 82 additions & 0 deletions test/multilevel/pinned_partitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Independent level partitions that are known not to nest; imported by the MPI helpers, not collected.

The controls in test_level_consistent_partitioning.py show that a finer level which
does not inherit the coarser one's partition misses parents. Cut by the graph
partitioner, an independent level is whatever the partitioner gives that run: the cut
changes from run to run and from build to build, and so does what it shares with the
level above. One CI run found no missing parent at all on the 4-rank hex mesh, and
-mat_partitioning_type current (the initial, contiguous partition) gives exactly that.
Pinned partitions make the control independent of the partitioner and its options.

``pin_independent_levels()`` replaces the graph partitioner for every level with
slabs across one coordinate axis, the axis turning with the level: level 0 is cut
across x, level 1 across y, level 2 across x again (z joins in 3D). Each rank owns a
near-equal, contiguous run of nodes (or elements, by barycenter) in the order of that
coordinate, so no rank is empty. Two consecutive levels are cut across different
axes, so a fine node at the far end of its slab has parents in another rank's slab,
beyond any one layer of overlap: the partitions do not nest on any of the test meshes,
whatever the build.

``PROTEUS_TEST_INDEPENDENT_PARTITIONS=slabs`` selects them in the helpers that take
that switch; any other non-empty value keeps the graph partitioner.
"""
import os

import numpy as np
from proteus import MeshTools

__all__ = ['pin_independent_levels', 'slab_owners', 'requested']


def requested():
"""Whether the environment asks for pinned (slab) independent partitions."""
return os.environ.get('PROTEUS_TEST_INDEPENDENT_PARTITIONS', '') == 'slabs'


def slab_owners(points, axis, size):
"""The owner of each point: ``size`` near-equal runs in the order of coordinate ``axis``.

Ties are broken by index (a stable sort), so the owners depend only on the points.
"""
order = np.argsort(np.asarray(points)[:, axis], kind='stable')
owners = np.empty(len(order), dtype=np.intc)
starts = (np.arange(size + 1) * len(order)) // size
for r in range(size):
owners[order[starts[r]:starts[r + 1]]] = r
return owners


def pin_independent_levels(dim):
"""Partition every level of the next multilevel meshes independently, by turning slabs.

Patches Mesh.partitionMesh so that a call without prescribed owners gets slab owners
instead of the graph partitioner; the n-th such call (the n-th level) is cut across
axis n mod dim. Turns level-consistent partitioning off. Returns the list the
patched method appends each level's axis to.
"""
from proteus import Comm
MeshTools.MultilevelMesh.levelConsistentPartitioning = False
original = MeshTools.Mesh.partitionMesh
axes = []

def partitionMesh(self, nLayersOfOverlap=1,
parallelPartitioningType=MeshTools.MeshParallelPartitioningTypes.node,
nodeOwners=None, elementOwners=None, nodeWeights=None):
size = Comm.get().size()
if size > 1 and nodeOwners is None and elementOwners is None:
axis = len(axes) % dim
axes.append(axis)
nodes = np.asarray(self.nodeArray)
if parallelPartitioningType == MeshTools.MeshParallelPartitioningTypes.node:
nodeOwners = slab_owners(nodes, axis, size)
else:
elementNodes = np.asarray(self.elementNodesArray)
elementOwners = slab_owners(nodes[elementNodes].mean(axis=1), axis, size)
nodeWeights = None
return original(self, nLayersOfOverlap=nLayersOfOverlap,
parallelPartitioningType=parallelPartitioningType,
nodeOwners=nodeOwners, elementOwners=elementOwners,
nodeWeights=nodeWeights)

MeshTools.Mesh.partitionMesh = partitionMesh
return axes
7 changes: 6 additions & 1 deletion test/multilevel/space_transfer_mpi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@
rank, size = comm.rank(), comm.size()
mpi = comm.comm.tompi4py()
testroot = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# a control: partition every level independently, as proteus used to
# a control: partition every level independently, as proteus used to; 'slabs' pins the
# levels to partitions that do not nest (pinned_partitions.py), anything else draws them
# from the graph partitioner
if os.environ.get('PROTEUS_TEST_INDEPENDENT_PARTITIONS'):
MeshTools.MultilevelMesh.levelConsistentPartitioning = False
from pinned_partitions import requested, pin_independent_levels
if requested():
pin_independent_levels(2)
spaceName = sys.argv[1]
nLevels = int(sys.argv[2])
degree = int(sys.argv[3])
Expand Down
26 changes: 14 additions & 12 deletions test/multilevel/test_level_consistent_partitioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
(``missing`` is zero), on simplices, quadrilaterals and hexahedra, for node and
element partitioning; and the off-process share of the prolongation is small;
* the control is real: without inheritance ``missing`` is large, so the zero
above is the partitioner's doing and not a property of the small meshes;
above is the partitioner's doing and not a property of the small meshes. The
control's independent levels are pinned (pinned_partitions.py: slabs across an
axis that turns with the level), not cut by the graph partitioner, whose cut is
a random draw that can nest by luck;
* the element table the subdomain hierarchy relies on agrees with matching
elements through their nodes, which does not depend on how the partitioner
numbers anything;
Expand Down Expand Up @@ -61,17 +64,16 @@ def _launch(helper, args, n_ranks, env_extra=None, check=True, timeout=300):
return result


def _measure(kind, consistent, by, n_ranks, levels=3):
result = _launch('level_consistent_mpi.py', [kind, str(levels), str(int(consistent)), by],
n_ranks)
def _measure(kind, consistent, by, n_ranks, levels=3, independent='graph'):
result = _launch('level_consistent_mpi.py',
[kind, str(levels), str(int(consistent)), by, independent], n_ranks)
line = [l for l in result.stdout.splitlines() if l.startswith('RESULT')][0]
return dict(item.split('=') for item in line.split()[1:])


OFFPROC_BOUND = 0.25
# the control: independent cuts are random, 0.24-0.99 over a few runs on a 4-rank quad mesh, so
# only a bound well below the worst luck can be asserted of them
INDEPENDENT_OFFPROC_FLOOR = 0.15
# the control's pinned slabs put 0.64-0.72 of the prolongation off process on these meshes
INDEPENDENT_OFFPROC_FLOOR = 0.5


@pytest.mark.parametrize('kind', ['tri', 'tet', 'quad', 'hex'])
Expand All @@ -90,19 +92,19 @@ def test_every_parent_of_an_owned_node_is_on_the_rank(kind, by, n_ranks):
@pytest.mark.parametrize('kind', ['tri', 'tet', 'quad', 'hex'])
@pytest.mark.parametrize('by', ['node', 'element'])
def test_independent_partitions_do_not_have_this_property(kind, by):
got = _measure(kind, False, by, 4)
# Pinned, not drawn: the graph partitioner's independent cuts change from run to
# run, and one in a few dozen nests by luck (missing == 0 on hex, CI run 42024054393).
# The slabs turn axis from level to level, so they never nest, on any build.
got = _measure(kind, False, by, 4, independent='slabs')
assert int(got['missing']) > 0, got
if by == 'node':
# independent cuts of the finer levels vary from run to run (hex on 4
# ranks: 0.40-0.99 over ten runs, quad 0.24-0.90, against a fixed ~0.12 inherited),
# so the control is only that they stay well above what inheritance gets
assert float(got['offproc']) > INDEPENDENT_OFFPROC_FLOOR, got


@pytest.mark.parametrize('space,degree', [('p2', 2), ('ncp1', 1)])
def test_operators_without_level_consistency_are_refused_not_wrong(space, degree):
result = _launch('space_transfer_mpi.py', [space, '3', str(degree)], 4,
env_extra={'PROTEUS_TEST_INDEPENDENT_PARTITIONS': '1'},
env_extra={'PROTEUS_TEST_INDEPENDENT_PARTITIONS': 'slabs'},
check=False)
assert result.returncode != 0, result.stdout
assert 'level-consistent' in result.stdout + result.stderr, result.stdout + result.stderr
13 changes: 9 additions & 4 deletions test/multilevel/test_without_petsc4py.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
PETSc C library is still used by the graph partitioner, unless the partition is
prescribed (test_partition_arrays.py runs multigrid with no PETSc at all).

* The building blocks agree with PETSc's exactly: DistMat's mult and
multTranspose, ParVec_mpi4py's forward insert and reverse add, on uneven
layouts with empty ranks.
* The building blocks agree with PETSc's, on uneven layouts with empty ranks:
ParVec_mpi4py's forward insert and reverse add exactly, DistMat's mult and
multTranspose to rounding. They agree exactly on the conda builds; the fleet's
pip PETSc on aarch64 (spark2) differs by 4.4e-16-8.9e-16, a fused
multiply-add, so the products are held to the rounding bound distmat_mpi.py
prints (about 1e-14 here) instead of to zero.
* MGM run with petsc4py blocked (without_petsc4py.py) reproduces the PETSc path
at every rank count, for nodal P1, P2, discontinuous P1 and hexahedral Q1. The
PETSc path is in turn the serial cycle (test_parallel_mgm.py and friends), so
Expand Down Expand Up @@ -62,8 +65,10 @@ def _launch(args, n_ranks, blocked, timeout=300):
def test_building_blocks_agree_with_petsc(n_ranks):
pytest.importorskip('petsc4py')
got = _launch(['distmat_mpi.py', '40', '23'], n_ranks, blocked=False)
for key in ('mult', 'multTranspose', 'forward', 'forward_truth', 'reverse'):
for key in ('forward', 'forward_truth', 'reverse'):
assert float(got[key]) == 0.0, got
for key in ('mult', 'multTranspose'):
assert float(got[key]) <= float(got[key + '_bound']), got


CASES = {
Expand Down
1 change: 1 addition & 0 deletions test/post_processing/test_velocity_postprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def test_a_dg_variant_balances_every_element(tmp_path, variant, order):


@pytest.mark.skipif(MPIEXEC is None, reason="mpiexec not available")
@pytest.mark.mumps("its parallel DG solve is LU through MUMPS (vpp_partition_mpi.py)")
@pytest.mark.parametrize("variant,order,ranks", DG_PARALLEL, ids=["%s-P%d-%dranks" % case for case in DG_PARALLEL])
def test_a_dg_variant_in_parallel_is_the_serial_answer(tmp_path, variant, order, ranks):
"""Element partition, one layer of overlap: every owned element has its neighbours. 4e-12 measured."""
Expand Down
Loading
Loading