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
154 changes: 154 additions & 0 deletions .github/scripts/validate_nsb_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Run NSB fixtures against a wheel in a separate consumer without build tools."""

import argparse
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import venv


ROOT = Path(__file__).resolve().parents[2]


def run(command, *, cwd, env, timeout=600):
command = [str(value) for value in command]
print("$ " + " ".join(command), flush=True)
with subprocess.Popen(command, cwd=cwd, env=env, start_new_session=True) as child:
try:
status = child.wait(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
raise
if status:
# Kill any surviving MPI workers before reporting the failure.
try:
os.killpg(child.pid, signal.SIGKILL)
except ProcessLookupError:
pass
raise subprocess.CalledProcessError(status, command)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'--backend', choices=('serial', 'openmpi', 'mpich'), required=True
)
parser.add_argument('--runtime-version', required=True)
parser.add_argument('--wheel-dir', type=Path, required=True)
parser.add_argument('--work-dir', type=Path, required=True)
args = parser.parse_args()
work = args.work_dir.resolve()
wheels = args.wheel_dir.resolve()
if work == ROOT or ROOT in work.parents:
parser.error('consumer directory must be outside the checkout')
work.mkdir(parents=True, exist_ok=True)
if any(work.iterdir()):
parser.error('consumer directory must be empty')
pymfem_wheels = list(wheels.glob('mfem-*.whl'))
if len(pymfem_wheels) != 1:
parser.error('expected exactly one PyMFEM wheel')
mpi = args.backend != 'serial'
env = dict(os.environ)
for key in ('PYTHONPATH', 'PYTHONHOME', 'PIP_TARGET', 'PIP_PREFIX', 'PIP_USER'):
env.pop(key, None)
env.update(PYTHONNOUSERSITE='1', OPENBLAS_NUM_THREADS='1', OMP_NUM_THREADS='1')
consumer = work / 'venv'
venv.EnvBuilder(with_pip=True).create(consumer)
python = consumer / 'bin/python'
run([python, '-m', 'pip', 'install', f'numba-swig-bridge-rt=={args.runtime_version}',
'numba==0.68.0', 'numpy>=2.4.1', 'scipy'], cwd=work, env=env)
if mpi:
mpi_wheels = list(wheels.glob('mpi4py-*.whl'))
if len(mpi_wheels) != 1:
parser.error('expected the source-built mpi4py wheel')
run([python, '-m', 'pip', 'install', '--no-deps', *mpi_wheels], cwd=work, env=env)
run([python, '-m', 'pip', 'install', '--no-deps', *pymfem_wheels], cwd=work, env=env)
run([python, '-m', 'pip', 'check'], cwd=work, env=env)

# Copy only fixture inputs; never copy the source mfem package.
fixtures = work / 'fixtures'
for directory in ('examples', 'data'):
shutil.copytree(ROOT / directory, fixtures / directory,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '*.gf'))
tests = fixtures / 'test/bridge'
tests.mkdir(parents=True)
for source in (ROOT / 'test/bridge').glob('test_*.py'):
if source.name != 'test_nsb_state_build.py':
shutil.copy2(source, tests / source.name)
env['PATH'] = str(python.parent)
mpi_command = []
if mpi:
tools = work / 'mpi-tools'
tools.mkdir()
original_path = os.environ.get('PATH')
launcher = shutil.which(f'mpiexec.{args.backend}', path=original_path)
if not launcher:
parser.error('selected MPI launcher is unavailable')
(tools / 'mpiexec').symlink_to(launcher)
for name in ('ssh', 'rsh', 'orted', 'prted', 'hydra_pmi_proxy'):
tool = shutil.which(name, path=original_path)
if tool:
(tools / name).symlink_to(tool)
env['PATH'] += os.pathsep + str(tools)
if args.backend == 'openmpi':
env['OMPI_MCA_rmaps_base_oversubscribe'] = '1'
mpi_command = [tools / 'mpiexec', '-n', '2']

check = work / 'check_install.py'
check.write_text(
"import importlib, importlib.util, pathlib, shutil, sys\n"
"assert importlib.util.find_spec('numba_swig_bridge') is None\n"
"assert importlib.util.find_spec('swig') is None\n"
"assert shutil.which('swig') is None\n"
+ ("assert importlib.util.find_spec('mpi4py') is None\n" if not mpi else '')
+ "import mfem, nsb_rt\n"
"assert pathlib.Path(mfem.__file__).resolve().is_relative_to(pathlib.Path(sys.prefix))\n"
"assert pathlib.Path(nsb_rt.__file__).resolve().is_relative_to(pathlib.Path(sys.prefix))\n"
f"assert nsb_rt.__version__ == {args.runtime_version!r}\n"
"backend = importlib.import_module('mfem.' + sys.argv[1])\n"
"registration = backend.get_bridge_registration()\n"
"assert pathlib.Path(registration.__file__).resolve().is_relative_to(pathlib.Path(sys.prefix))\n"
"print('Installed wheel and runtime verified:', mfem.__file__, flush=True)\n"
)
run([python, '-I', check, 'ser'], cwd=work, env=env)
completed = []
excluded = {'test_ex18p.py', 'test_ex9p.py', 'test_ex19p.py', 'test_parallel_foundation.py'}
for test in sorted(tests.glob('test_*.py')):
if test.name not in excluded:
run([python, '-I', test], cwd=work, env=env, timeout=1200)
completed.append(test.name)
if mpi:
mpi_check = work / 'check_mpi.py'
mpi_check.write_text(
"from mpi4py import MPI\n"
"version = MPI.Get_library_version()\n"
f"assert {args.backend!r} in version.lower().replace(' ', ''), version\n"
"assert MPI.COMM_WORLD.size == 2\n"
"print('MPI rank', MPI.COMM_WORLD.rank, 'size', MPI.COMM_WORLD.size, flush=True)\n"
)
run([*mpi_command, python, '-I', mpi_check], cwd=work, env=env)
run([*mpi_command, python, '-I', check, 'par'], cwd=work, env=env)
for name in ('test_parallel_foundation.py', 'test_ex18p.py'):
run([*mpi_command, python, '-I', tests / name], cwd=work, env=env)
completed.append(name)
# These acceptance drivers launch their own two-rank workers.
for name in ('test_ex9p.py', 'test_ex19p.py'):
run([python, '-I', tests / name], cwd=work, env=env, timeout=1800)
completed.append(name)
run([*mpi_command, python, '-I', fixtures / 'examples/bridge/ex4p.py',
'--refine', '0', '--compare'], cwd=work, env=env)
completed.append('ex4p.py --compare')
(work / 'report.json').write_text(json.dumps({
'backend': args.backend, 'runtime': args.runtime_version,
'wheel': pymfem_wheels[0].name, 'checks': completed, 'status': 'passed',
}, indent=2) + '\n')
print('NSB installed-consumer validation passed:', args.backend, flush=True)


if __name__ == '__main__':
main()
81 changes: 81 additions & 0 deletions .github/workflows/Build-and-test-NSB-callable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Build and test NSB (callable)

on:
workflow_call:
inputs:
backend:
description: 'serial, openmpi or mpich'
type: string
required: true
python-version:
type: string
default: '3.13'
runtime-version:
type: string
default: '0.15.3'

permissions:
contents: read

jobs:
wheel-consumer:
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
OPENBLAS_NUM_THREADS: '1'
OMP_NUM_THREADS: '1'
PYMFEM_BUILD_NCPU: '2'
NSB_BACKEND: ${{ inputs.backend }}
NSB_RUNTIME_VERSION: ${{ inputs.runtime-version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Validate backend input
run: |
case "$NSB_BACKEND" in
serial|openmpi|mpich) ;;
*) echo "Unsupported backend: $NSB_BACKEND" >&2; exit 2 ;;
esac
- name: Set up MPI (including Ubuntu MPICH hotfix)
if: inputs.backend != 'serial'
uses: mpi4py/setup-mpi@v1
with:
mpi: ${{ inputs.backend }}
- name: Install native build tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends build-essential zlib1g-dev openssh-client
- name: Prepare separate build environment
run: |
python -m venv "$RUNNER_TEMP/nsb-build"
"$RUNNER_TEMP/nsb-build/bin/python" -m pip install --upgrade \
'pip>=25.1' 'setuptools>=80.0.1' wheel 'cmake>=4.0.0' \
'swig==4.5.1' 'numpy>=2.4.1' 'numba==0.68.0' scipy \
"numba-swig-bridge-rt==$NSB_RUNTIME_VERSION"
mkdir -p "$RUNNER_TEMP/nsb-wheels"
if [ "$NSB_BACKEND" != serial ]; then
MPI4PY_BUILD_MPICC="mpicc.$NSB_BACKEND" \
"$RUNNER_TEMP/nsb-build/bin/python" -m pip wheel \
'mpi4py==4.1.2' --no-deps --no-binary=mpi4py --no-cache-dir \
--wheel-dir "$RUNNER_TEMP/nsb-wheels"
"$RUNNER_TEMP/nsb-build/bin/python" -m pip install "$RUNNER_TEMP"/nsb-wheels/mpi4py-*.whl
fi
- name: Build PyMFEM wheel from committed artifacts
run: |
flags=(-Cwith-numba-swig-bridge=Yes)
if [ "$NSB_BACKEND" != serial ]; then
flags+=(-Cwith-parallel=Yes "-CMPICC=mpicc.$NSB_BACKEND" "-CMPICXX=mpicxx.$NSB_BACKEND")
fi
"$RUNNER_TEMP/nsb-build/bin/python" -I -c \
"import importlib.util; assert importlib.util.find_spec('numba_swig_bridge') is None"
"$RUNNER_TEMP/nsb-build/bin/python" -m pip wheel . \
--no-deps --no-build-isolation --verbose \
--wheel-dir "$RUNNER_TEMP/nsb-wheels" "${flags[@]}"
- name: Test fresh installed wheel consumers
run: |
"$RUNNER_TEMP/nsb-build/bin/python" .github/scripts/validate_nsb_consumer.py \
--backend "$NSB_BACKEND" --runtime-version "$NSB_RUNTIME_VERSION" \
--wheel-dir "$RUNNER_TEMP/nsb-wheels" \
--work-dir "$RUNNER_TEMP/nsb-consumer"
22 changes: 22 additions & 0 deletions .github/workflows/Build-and-test-NSB-dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Build and test NSB (dispatch)

on:
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
nsb:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ci') }}
strategy:
fail-fast: false
matrix:
backend: [serial, openmpi, mpich]
name: NSB | ${{ matrix.backend }} | Python 3.13
uses: ./.github/workflows/Build-and-test-NSB-callable.yml
with:
backend: ${{ matrix.backend }}

4 changes: 3 additions & 1 deletion .github/workflows/build-and-test-callable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ jobs:
- name: Install MPI
if: inputs.parallel
run: |
sudo apt-get update
sudo apt-get install openmpi-bin libopenmpi-dev
export OMPI_MCA_rmaps_base_oversubscribe=1
# (broken?) sudo apt-get install mpich libmpich-dev
Expand All @@ -123,7 +124,8 @@ jobs:
- name: Download CUDA
if: inputs.cuda && steps.cache-cuda.outputs.cache-hit == false
run: |
CUDA_URL="https://developer.download.nvidia.com/compute/cuda/${{ inputs.cuda-toolkit-version }}/local_installers/cuda_${{ inputs.cuda-toolkit-version }}_${{ inputs.cuda-driver-version }}_linux.run"
## CUDA_URL="https://developer.download.nvidia.com/compute/cuda/${{ inputs.cuda-toolkit-version }}/local_installers/cuda_${{ inputs.cuda-toolkit-version }}_${{ inputs.cuda-driver-version }}_linux.run"
CUDA_URL="https://developer.download.nvidia.com/compute/cuda/${{ inputs.cuda-toolkit-version }}/local_installers/cuda_${{ inputs.cuda-toolkit-version }}_${{ inputs.cuda-driver-version }}_[...]
curl -o ~/cache/cuda.run --create-dirs $CUDA_URL

- name: Install CUDA
Expand Down
28 changes: 26 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
*.so
*.o
*.a
/external/
.DS_store
#*
.#*
Expand All @@ -12,9 +13,32 @@ build
dist
test/*
!test/*.py
!test/bridge/
!test/bridge/*.py
!test/bridge/README.md
!test/cpp
!test/cpp/
!test/issues/*.py
!test/issues/data_3960/VisIt_volume
*.egg-info
*.py#
*.egg-info
*.py#
mfem/_ser/*_wrap.cxx
mfem/_ser/*_wrap.h
mfem/_par/*_wrap.cxx
mfem/_par/*_wrap.h
mfem/_ser/*.py
mfem/_par/*.py
!mfem/_par/setup.py
!mfem/_par/__init__.py
!mfem/_par/tmop_modules.py
!mfem/_par/bridge_registration.py
!mfem/_ser/setup.py
!mfem/_ser/__init__.py
!mfem/_ser/tmop_modules.py
!mfem/_ser/bridge_registration.py
mfem/__sha__.py
mfem/common/existing_mfem_headers.i
mfem/_ser/director_descriptor.json
mfem/_ser/ordinary_descriptor.json
mfem/_par/director_descriptor.json
mfem/_par/ordinary_descriptor.json
11 changes: 10 additions & 1 deletion INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ and `--with-gslib` flags as follows.
pip install . -C"with-parallel=Yes" -C"with-gslib=Yes"
```

(Warning) The migration to `pip install . ` is on-going effort and Some of the example commands are not tested yet, which are indicated by using old conversion of "python setup.py install XXXX"


## Environmental variable
`PYMFEM_BUILD_NCPU` : set number of parallel jobs used when calling SWIG and build-extension.
Expand Down Expand Up @@ -182,6 +182,15 @@ pip install . -C"skip-ext=Yes" -C"skip-swig=Yes" --verbose
pip install . -C"with-parallel=Yes" -C"skip-ext=Yes" -C"skip-swig=Yes" --verbose
```

### Experimental Numba bridge (prebuilt binaries are not available)
```shell
pip install ".[bridge]" -C"with-numba-swig-bridge=Yes"
```

The `[bridge]` extra installs the bridge runtime in the target environment;
`-C"with-numba-swig-bridge=Yes"` enables bridge support in the source build.
Both are needed for a complete source installation.

### Other options
`--unverifiedSSL` :
This addresses error relating SSL certificate. Typical error message is
Expand Down
5 changes: 5 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ include docs/*.txt
include docs/*.png
include external/README.txt
exclude mfem/*/*.so

include mfem/_ser/bridge.toml mfem/_par/bridge.toml
recursive-include mfem/common/bridges *.i
recursive-include mfem/_ser/nsb_artifacts *
recursive-include mfem/_par/nsb_artifacts *
30 changes: 20 additions & 10 deletions _build_system/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@
import build_globals as bglb


def get_requires_for_build_wheel(config_settings=None):
ret = _orig.get_requires_for_build_wheel(config_settings)
def _enabled(config_settings, flag):
"""Return whether a PEP 517 boolean build setting is enabled."""
if not config_settings:
return False
value = config_settings.get(flag, "No")
if isinstance(value, (list, tuple)):
value = value[-1] if value else "No"
return str(value).upper() in ("YES", "TRUE", "1")

need_mpi = False
if config_settings is not None:
for flag in ("with-parallel", ):
value = config_settings.pop(flag, "No")
if value.upper() in ("YES", "TRUE", "1"):
need_mpi = True
break

if need_mpi:
def get_requires_for_build_wheel(config_settings=None):
# Do not consume config_settings here: build_wheel passes the same setting
# on to setup.py, where build_config.py enables the matching feature.
ret = _orig.get_requires_for_build_wheel(config_settings)
if _enabled(config_settings, "with-parallel"):
ret = ret + ['mpi4py']
if _enabled(config_settings, "with-numba-swig-bridge"):
minimum = '0.15.1' if _enabled(config_settings, 'with-parallel') else '0.15.0'
ret = ret + ['numba-swig-bridge-rt>=' + minimum]
return ret


Expand All @@ -28,4 +34,8 @@ def build_wheel(*args, **kwargs):
bglb.cfs = args[1]
if bglb.cfs is None:
bglb.cfs = {}
else:
# process_cmd_options consumes its input. Keep the caller-owned PEP 517
# mapping intact for build frontends that reuse it.
bglb.cfs = dict(bglb.cfs)
return _orig.build_wheel(*args, **kwargs)
Loading
Loading