Skip to content

Run the hybrid solver on hypre's threads-as-ranks backend (no mpirun) - #114

Open
danielepanozzo wants to merge 2 commits into
polyfem:hybrid-solverfrom
danielepanozzo:hybrid-solver-thread-mpi
Open

Run the hybrid solver on hypre's threads-as-ranks backend (no mpirun)#114
danielepanozzo wants to merge 2 commits into
polyfem:hybrid-solverfrom
danielepanozzo:hybrid-solver-thread-mpi

Conversation

@danielepanozzo

Copy link
Copy Markdown
Collaborator

Stacked on #112 — please read that one first; this PR only changes how the hybrid solver gets its ranks.

What this does

#112's hybrid solver needs MPI, so the whole application has to be launched under mpirun. That is fine for a driver but not for a library: anything embedding polysolve inherits the requirement.

hypre can now build its MPI surface on threads of one process instead of processes, so the ranks become threads and mpirun goes away. The resulting binary links no libmpi at all:

$ ldd tests/unit_tests | grep libmpi
$

MPI is not removed as a concept — POLYSOLVE_WITH_MPI=ON still selects a rank-parallel hybrid solver. It just no longer means OpenMPI.

How

  • tmpi_mpi_compat.hpp (new) supplies the few MPI facilities hypre's backend does not already provide. hypre maps MPI_Bcast/Allreduce/Scatterv/… onto its own surface already, so what was missing is: the MPI-3 shared-memory windows (when ranks are threads, a "shared" window is just a pointer broadcast), MPI_IN_PLACE, and MPI_Init/Initialized/Finalized.

  • The solver starts its own ranks. The first hybrid solver constructed calls hypre_tmpi_team_start(); the calling thread becomes rank 0 and keeps driving, and the workers land in the command loop this PR's parent already has. They now return from their thread function rather than std::exit(0), and the team is refcounted so the last solver destroyed shuts it down and the rest of the process sees a one-rank world again.

  • is_running_worker_loop and worker_registry became thread_local. They were per-rank only because each rank used to be a process; as threads they are shared, and the workers raced on them. This is the main hazard when moving rank-parallel code onto threads, and worth knowing about for any other statics added to this solver later.

Dependency note

cmake/recipes/hypre.cmake currently points at danielepanozzo/hypre@thread-mpi-backend, where the backend lives as an open PR. That pointer should move to upstream hypre before this merges.

Status

Full suite passes with the ranks as threads:

$ HYPRE_TMPI_NUM_THREADS=4 ./unit_tests
All tests passed (1569 assertions in 27 test cases)

Rank count comes from HYPRE_TMPI_NUM_THREADS, defaulting to the cores online.

Performance numbers are still to come — I am benchmarking the hybrid solver as OpenMPI vs threads-as-ranks, CPU and GPU, on an idle 64-core machine, and will post the comparison here. Treat the PR as functionally complete but not yet performance-justified.

🤖 Generated with Claude Code

The hybrid solver needed MPI, which meant the whole application had to be
launched under mpirun. That is fine for a driver but not for a library:
anything embedding polysolve inherited the requirement.

hypre can now build its MPI surface on threads of one process
(HYPRE_ENABLE_THREAD_MPI), so the ranks become threads and mpirun goes
away. The resulting binary links no libmpi at all.

What this needed:

- tmpi_mpi_compat.hpp: the few MPI facilities hypre's backend does not
  already provide. hypre maps MPI_Bcast/Allreduce/Scatterv/... onto its
  own surface already; missing were the MPI-3 shared-memory windows
  (with threads, "shared memory" is just a pointer broadcast),
  MPI_IN_PLACE, and MPI_Init/Initialized/Finalized.

- The ranks are started by the solver rather than by mpirun. The first
  hybrid solver constructed calls hypre_tmpi_team_start(), the calling
  thread becomes rank 0 and keeps driving, and the workers sit in the
  existing command loop. They now return from their thread function
  instead of std::exit(0), and the last solver destroyed shuts the team
  down so the rest of the process sees a one-rank world again.

- is_running_worker_loop and worker_registry are thread_local. They were
  per-rank only because each rank used to be a process; as threads they
  were shared and the workers raced on them.

MPI remains available: POLYSOLVE_WITH_MPI still selects a rank-parallel
hybrid solver, it just no longer implies OpenMPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.07%. Comparing base (bc147c1) to head (6fe15f6).

Additional details and impacted files
@@              Coverage Diff               @@
##           hybrid-solver     #114   +/-   ##
==============================================
  Coverage          80.07%   80.07%           
==============================================
  Files                 52       52           
  Lines               2138     2138           
  Branches             284      284           
==============================================
  Hits                1712     1712           
  Misses               426      426           
Flag Coverage Δ
polysolve 80.07% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The Catch2 tests cannot time the multi-rank path: they drive the solver
from rank 0 while the other ranks sit in the worker loop, and under
mpirun at more than one rank the suite is torn down when a worker leaves
the loop. Timing that measures the teardown, not the solve.

tests/bench_spmd.cpp is the same source built into both an OpenMPI and a
thread-MPI build, so the two backends are compared on one driver rather
than on two that happen to look similar. It builds a 7-point Laplacian
of a given grid size, solves it, and prints setup/solve/total plus the
relative residual so a run that converged differently cannot be mistaken
for a run that was merely faster.

scripts/bench_hybrid.sh sweeps rank counts on both backends. It takes an
flock so two sweeps cannot halve each other's scores, refuses to start
against a loaded machine rather than quietly reporting contended
numbers, and interleaves the backends inside each repetition so drift
hits both equally.

scripts/bench_report.py renders the CSV as Markdown: best-of-N rather
than mean, the worst run-to-run spread so the reader can judge whether a
gap is real, and a check that both backends reached the same residual at
each rank count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@danielepanozzo

Copy link
Copy Markdown
Collaborator Author

Benchmark: OpenMPI processes vs thread-MPI threads

Threadripper PRO 3995WX, 64 cores, single NUMA node, idle. 7-point Laplacian, 120³ = 1.73M unknowns, CPUHybrid. Both sides run the same driver (tests/bench_spmd.cpp) via scripts/bench_hybrid.sh, added in this PR.

Grid 120^3, best of 3 runs; worst run-to-run spread 10.7%.
Both backends reach an identical relative residual at every rank count (1: 6.550e-11, 2: 3.942e-11, 4: 7.911e-11, 8: 7.301e-11, 16: 3.993e-11, 32: 4.152e-11, 64: 5.494e-11).

ranks OpenMPI (processes) total (s) thread-MPI (threads) total (s) thread-MPI vs OpenMPI
1 6.01 5.50 1.09x
2 3.46 3.29 1.05x
4 1.85 1.92 0.96x
8 1.13 1.35 0.84x
16 0.82 1.02 0.80x
32 0.82 1.00 0.82x
64 0.87 1.20 0.73x
setup vs solve
backend ranks setup (s) solve (s)
OpenMPI (processes) 1 1.76 4.25
OpenMPI (processes) 2 0.94 2.51
OpenMPI (processes) 4 0.58 1.27
OpenMPI (processes) 8 0.38 0.75
OpenMPI (processes) 16 0.28 0.54
OpenMPI (processes) 32 0.28 0.54
OpenMPI (processes) 64 0.31 0.56
thread-MPI (threads) 1 1.58 3.92
thread-MPI (threads) 2 1.04 2.25
thread-MPI (threads) 4 0.67 1.26
thread-MPI (threads) 8 0.61 0.73
thread-MPI (threads) 16 0.52 0.50
thread-MPI (threads) 32 0.51 0.49
thread-MPI (threads) 64 0.57 0.63

Reading this

Correctness first: both backends reach an identical relative residual at every rank count. The residual changes with the rank count — the partitioning changes, so AMG builds a different hierarchy — but never between backends at the same count.

Threads win at 1–2 ranks, OpenMPI wins from 4 up, ending 27% ahead at 64. The loss is not where I expected: look at the setup/solve split. thread-MPI's solve is competitive and actually faster at 16 and 32 ranks (0.50s vs 0.54s). The entire deficit is in setup, which plateaus around 0.51s for threads while OpenMPI keeps falling to 0.28s. AMG setup is the communication-heavy phase, so this points at the thread-MPI collective/matching path rather than anything about the solve.

Caveat on precision: worst run-to-run spread is 10.7%, which is larger than I would like. The 1–2 rank and 32–64 rank conclusions are safely outside that; the 4-rank crossover (0.96x) is inside it and should be read as "roughly equal", not as a win for either.

What this does and does not justify

If you need mpirun gone — embedding polysolve in a library, as this PR targets — the cost is real but bounded: parity to 4 ranks, and about 20–27% beyond that. If you are already running under mpirun and scaling past 8 ranks, OpenMPI remains the faster choice and this PR does not ask you to give it up.

Still to come

GPU numbers. POLYSOLVE_WITH_CUDA=ON also forces HYPRE_USING_GPU, so CPU and GPU need separate builds; those are queued. One known issue may bite there: multiple ranks sharing a single GPU segfault at teardown in hypre's CUDA path, so the GPU comparison may only be meaningful at one rank.

@danielepanozzo

Copy link
Copy Markdown
Collaborator Author

GPU results

Same driver, same 120³ problem, RTX 3080 Ti. Best of 3, load 2.1–2.2, spread under 2%.

configuration ranks setup (s) solve (s) total (s)
GPUHybrid, OpenMPI build 1 0.083 0.248 0.332
GPUHybrid, thread-MPI build 1 0.084 0.252 0.336
CPUHybrid, OpenMPI, best (32 ranks) 32 0.28 0.54 0.82
CPUHybrid, thread-MPI, best (32 ranks) 32 0.51 0.49 1.00

One GPU is 2.5× faster than all 64 CPU cores at their best, and 18× faster than a single rank.

The two builds are 1.2% apart, which is the point of the table. GPUHybridSolver uses cuDSS and hypre but no MPI, so replacing OpenMPI with threads-as-ranks leaves it untouched — and the measurement confirms it rather than assuming it. Residual is identical (6.602e-11) across both.

A correction worth recording

My first GPU run reported times identical to CPU. That was not the GPU being slow: CPUHybrid calls HYPRE_SetExecutionPolicy(HYPRE_EXEC_HOST), so it stays on the host even in a CUDA build. Enabling POLYSOLVE_WITH_CUDA does not move the hybrid CPU solver onto the device — you get the GPU by selecting GPUHybrid. Worth knowing before reading a CUDA build as "the GPU numbers".

Note also that POLYSOLVE_WITH_CUDA=ON forces HYPRE_USING_GPU/HYPRE_ENABLE_CUDA, so CPU and GPU figures must come from separate build trees; that is why the CPU table above was measured in non-CUDA builds.

Summary across both comments

  • Dropping mpirun costs nothing up to 4 ranks and 20–27% beyond it, all of it in AMG setup, not the solve.
  • The GPU path is unaffected by the change.
  • Every configuration converges to the same residual at the same rank count.

Comment thread CMakeLists.txt
find_package(MPI QUIET)
if (NOT MPI_CXX_FOUND)
message(WARNING "POLYSOLVE_WITH_MPI was requested but MPI was not found, proceeding without MPI dependent solvers.")
if (FALSE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean this

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the hybrid linear solver’s “rank” acquisition to use hypre’s threads-as-ranks backend so embedding applications no longer need to launch under mpirun, while keeping POLYSOLVE_WITH_MPI=ON as the selector for rank-parallel hybrid solving.

Changes:

  • Introduces a thread-MPI compatibility shim (tmpi_mpi_compat.hpp) to cover missing MPI facilities (shared windows, MPI_IN_PLACE, init-state calls).
  • Refactors CPUHybridSolver to start/join hypre thread ranks internally, and makes worker-loop state/registry thread_local to avoid cross-thread races.
  • Adds an SPMD benchmark driver plus scripts to sweep rank counts and report results.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
CMakeLists.txt Adjusts MPI discovery behavior when POLYSOLVE_WITH_MPI is enabled.
cmake/recipes/hypre.cmake Switches hypre recipe to thread-MPI backend (fork/branch) and toggles hypre MPI options.
src/polysolve/linear/tmpi_mpi_compat.hpp New MPI-compat layer for hypre thread-MPI builds (windows, in-place reductions, init-state).
src/polysolve/linear/CPUHybridSolver.hpp Makes worker state thread_local; adds rank-team lifetime management helpers.
src/polysolve/linear/CPUHybridSolver.cpp Starts/stops thread ranks via hypre tmpi team; worker threads return instead of exiting.
tests/CMakeLists.txt Adds bench_spmd executable for benchmarking.
tests/bench_spmd.cpp New SPMD benchmark program that drives the hybrid solver from all ranks.
scripts/bench_hybrid.sh New sweep script to benchmark OpenMPI vs thread-MPI backends.
scripts/bench_report.py New CSV→Markdown report generator for benchmark output.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread CMakeLists.txt
Comment on lines 178 to +182
if(POLYSOLVE_WITH_MPI)
# hypre's thread-MPI backend supplies the ranks, so no MPI installation is
# needed. Keep looking for one only to satisfy anything that still wants it.
find_package(MPI QUIET)
if (NOT MPI_CXX_FOUND)
message(WARNING "POLYSOLVE_WITH_MPI was requested but MPI was not found, proceeding without MPI dependent solvers.")
if (FALSE)
Comment thread cmake/recipes/hypre.cmake
Comment on lines 43 to 49
include(CPM)
CPMAddPackage(
NAME hypre
GITHUB_REPOSITORY hypre-space/hypre
GIT_TAG 7e247a231ebdeb44b06c7c9d3b5bee3bac21123f
GITHUB_REPOSITORY danielepanozzo/hypre
GIT_TAG thread-mpi-backend
SOURCE_SUBDIR src
)
Eigen::setNbThreads(1);
HYPRE_SetMemoryLocation(HYPRE_MEMORY_HOST);
HYPRE_SetExecutionPolicy(HYPRE_EXEC_HOST);
spdlog::set_level(spdlog::level::off);
Comment thread scripts/bench_hybrid.sh
Comment on lines +36 to +40
if [ "$backend" = openmpi ]; then
line=$(mpirun -quiet --oversubscribe -np "$n" "$MPI_BUILD/tests/bench_spmd" "$GRID" 2>/dev/null | tail -1)
else
line=$(HYPRE_TMPI_NUM_THREADS=$n "$TMPI_BUILD/tests/bench_spmd" "$GRID" 2>/dev/null | tail -1)
fi
Comment thread scripts/bench_report.py
runs = defaultdict(list)
grids = set()
res = defaultdict(set) # residual per rank count, not overall
with open(path) as fh:
Comment on lines +55 to +63
if (sendbuf == MPI_IN_PLACE)
{
const size_t nb = polysolve::tmpi_compat::datatype_size(dt) * (size_t) count;
void *tmp = std::malloc(nb ? nb : 1);
std::memcpy(tmp, recvbuf, nb);
const HYPRE_Int rc = hypre_MPI_Allreduce(tmp, recvbuf, count, dt, op, comm);
std::free(tmp);
return rc;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants