Skip to content
All articles

Implementing MPI + OpenMP Hybrid to the End, Then Going Back to Pure MPI

The CPU path of a GPU-oriented solver was slow, so we added OpenMP and then went on to try a hybrid with MPI. The hybrid led by 11% on 8 cores but lost the lead at 32, and we found out why most CFD codes go pure MPI on the CPU.

Research
2026. 08. 16

1. Overview

Starting point — the CPU path of GPU code is slow

Run a solver written for the GPU on a CPU and it is slower than code optimised for the CPU from the start, because the data structures and kernel decomposition follow the GPU execution model.

The CPU path cannot simply be dropped, though. It has to run where there is no GPU, it is the reference against which GPU kernel correctness is checked, and debugging during development happens on the CPU.

That is where the question began. How much is recovered by adding OpenMP to the CPU path?

How we ended up at the hybrid

Filling in OpenMP coverage led to the next question. Instead of filling every core with MPI ranks, what happens if we reduce the ranks and fill each one with threads? That is hybrid parallelisation.

The two approaches use the same cores but pay different costs. MPI processes are separate, so every subdomain boundary requires communication and a copy of the data exists per rank. OpenMP shares an address space, so there is neither communication nor duplication, but it pays in barriers between threads, false sharing and lost NUMA locality.

Reducing the ranks looked like it would buy two things.

  1. Less communication — fewer subdomain boundaries
  2. Less rank-local preconditioner degradation — coarser ranks mean the preconditioner sees a larger subdomain

For that trade to hold, one premise is needed: OpenMP inside a rank has to come close to MPI. If filling with threads is much slower than filling with ranks, the gain from reduced communication is cancelled out.

One thing nagged. Most CFD codes go pure MPI on the CPU. If the hybrid were that advantageous it would already be in wide use, and since it is not, there must be a reason — we wanted to find out what it was first-hand.

The measurement

The same mesh and the same case were run on 4, 8, 32 and 128 cores in three configurations — pure MPI, pure OpenMP and hybrid — and the wall clock compared.

The subject is the CPU execution path of our in-house CFD solver. The meshes are ONERA M6 (306,577 nodes) and NASA DPW CRM (3,600,000 nodes), and the physics is compressible RANS (Spalart–Allmaras).

2. Implementation scope and environment

Measurement environment

A cluster with 16 cores per socket, 2 sockets (32 cores) per node, and 4 nodes for 128 cores.

The 4-core and 8-core measurements were taken separately on a single-socket, 10-core development machine. The environments differ, so absolute times are not comparable across them; below we compare only the relative difference between pure MPI and hybrid within the same machine.

Implementation scope

Before judging anything we completed the OpenMP coverage first, so that the reason for rejection could not be “the implementation was incomplete.”

StageEffect (8 threads, M6 306k)
Parallelised turbulence and point loops + edge renumbering55.8 s → 42.7 s (speedup 4.15 → 4.71×)
OpenMP in the CPU linear solver74.0 s → 41.8 s (−43%, speedup 2.50 → 4.31×)

The serial fraction back-calculated with Amdahl’s law fell from 38% to about 13%. Pure OpenMP itself, we judged, had done its job.

Partial parallelisation of the linear solver gained almost nothing. Parallelising only the SGS sweep gave a 12% improvement on 8 threads; including SpMV and the vector operations brought it to −33%; and only when the diagonal inverse — which builds 300,000 5×5 inverse matrices at every Newton step — was included as well did it reach −43%.

3. Results

3.1 The winner changes with core count

Difference between pure MPI and the best hybrid by core count. The hybrid leads only at 8 cores.
Difference between pure MPI and the best hybrid by core count. The hybrid leads only at 8 cores.
CoresPure MPIBest hybridWinner
453.24 s57.71 s (np2×T2)MPI +8%
835.93 s31.91 s (np4×T2)Hybrid +11%
329.76 s11.26 s (np8×T4)MPI +15%

Seeing the hybrid lead at 8 cores, we read it as “the hybrid gets more favourable as cores increase.” The 32-core measurement showed that reading to be wrong.

The single-socket, 10-core range was not the range where MPI is strongest but the only range where the hybrid leads at all, and reading it as the start of a trend was the misinterpretation.

3.2 The configuration the design aimed at is the slowest

At all three scales the ordering was np8×T4 > np4×T8 > np2×T16. The figures below are for CRM 3.6M on 32 cores, solver phase only.

ConfigurationSolver phasevs MPI
Pure MPI np32312.1 s
Hybrid np8×T4348.8 s+12%
Hybrid np4×T8396.9 s+27%
Hybrid np2×T16501.2 s+61%

The shape the hybrid design was aiming at is one rank per socket (np2×T16). It is the most natural configuration in that it never crosses a NUMA boundary, and it was consistently the slowest of the three.

Smaller thread teams are faster — and the extreme of that is pure MPI.

The hypothesis that there is a wall at the socket boundary was itself correct. Pure OpenMP on 32 cores actually slows down when threads go from 16 to 32 (29.46 s → 30.78 s). But rather than reducing ranks to avoid that wall, increasing ranks so as never to meet it was faster.

3.3 The preconditioner gain does not exist

The second expectation — that fewer ranks make the rank-local preconditioner coarser and improve convergence — was tested on 4 nodes, 128 cores, with SGS used as the preconditioner throughout.

ConfigurationSolver phaseLinear iterations
Pure MPI 128 ranks121.1 s1.00
Hybrid 32 ranks×T4127.6 s1.00
Hybrid 8 ranks×T16162.3 s1.00

All three show linear iterations of 1.00: there is no degradation, and reducing ranks improves nothing.

The cause lies in the preconditioner implementation. Block SGS splits the row range per thread and sweeps block-locally, so the number of subdomains the preconditioner sees is ranks × threads — exactly the same as pure MPI. This could have been known by checking the preconditioner implementation before starting.

3.4 Scaling efficiency ranks in the opposite order

Absolute time and strong-scaling efficiency rank in exactly opposite orders.

Absolute time on 128 cores against 32→128 strong-scaling efficiency. The fastest configuration scales worst.
Absolute time on 128 cores against 32→128 strong-scaling efficiency. The fastest configuration scales worst.

Pure MPI starts from the fastest position and scales worst. Assuming that efficiency holds, the curves cross somewhere around 512 cores.

That extrapolation is an optimistic estimate from two data points, however. Partitioning CRM 3.6M across 2,048 cores leaves 1,800 nodes per rank, so the mesh runs out first. This finding is valid up to 128 cores; beyond that it has to be measured again on meshes of tens of millions of nodes.

4. Why

What the hybrid set out to reduce was communication. At this problem size, communication was not the bottleneck. The parallel efficiency of pure MPI was 44–50% at 32 cores and 64% at 128 — within the normal range.

In exchange for reducing something that was not the bottleneck, we took on barriers inside the thread team, false sharing and lost NUMA locality, and that cost exceeded the gain. The consistent ordering across all three scales — smaller teams are faster — supports this. A thread team costs in proportion to its size, and a team size of one is optimal.

The preconditioner gain never existed. As long as the number of subdomains is ranks × threads, reducing ranks does not make the preconditioner coarser.

5. What to check in the measurement

These are the traps we actually fell into. Get the measurement wrong and the conclusion inverts.

TrapSymptom
The solver reporting time with clock()clock() is the sum of CPU time over all threads, so the value grows with thread count. It produced the false observation that “parallelising makes it slower” when the actual speedup was 4.88×
OpenMP threads not pinnedThe scheduler piles threads onto hyperthreading sibling cores and the effective core count halves
Missing binding in MPI hybrid runsWith --bind-to none, every rank occupies the same physical cores. The difference is 93.07 s against 31.91 s — 2.9× — and measuring in that state yields the opposite conclusion, “the hybrid is useless”
Measuring memory with maxRSSIt is the peak of a single process, so it does not give the MPI node total
ps -C / ps commProcess names are truncated at 15 characters. Two builds became indistinguishable and every measurement came back zero
Judging on total wall clockDifferences in setup time get mixed in. In a real 30,000-iteration run, setup is 0.4% of the total

The first item matters most. Every measurement taken before it was fixed was invalid.

6. Conclusions

Pure MPI appears to be the better choice on performance alone, before the development and maintenance cost of the hybrid is even counted.

That is the conclusion after completing the OpenMP coverage, parallelising the linear solver as well, and measuring at four scales. Of the two gains the hybrid was expected to deliver, the speed gain was a local phenomenon at 8 cores, and the preconditioner gain never existed given how the subdomain count is formed.

The answer to the question we started from — how much of the CPU path of GPU-oriented code can be recovered with OpenMP — is “it is effective as far as cutting the serial fraction to 13%, but filling those cores with MPI ranks is still faster.”

And there is a reason most CFD codes go pure MPI on the CPU: a thread team costs in proportion to its size, and a team size of one is optimal.

Others reached the same conclusion first. The linear algebra library PETSc also attempted to add an MPI+threads hybrid, but performance was often worse than the MPI-everywhere approach despite the added code complexity, so hybrid support was removed and PETSc uses MPI only (Frustrated with MPI+Threads?, 2024). The 2016 PETSc meeting even included a talk titled To thread or not to thread? Why PETSc favors MPI-only.

There are reports in the other direction, though. Results published for PETSc show that as node counts grow, the subdomain per rank coarsens and the hybrid becomes favourable (arXiv:1303.5275). That points the same way as the scaling-efficiency inversion observed in §3.4, and it shows that the question is not which side is right but at what scale you measure. It is also why this article limits its finding to 128 cores.

Continue reading

Research

Dynamic CFL Control of a CFD Solver with an LLM

We built a controller that adjusts CFL in real time during a CFD run using a commercial LLM, and attached it to the Wake solver. The LLM alone diverged; combined with a fast rule-based safety layer, the hybrid converged 27% faster than a human-tuned constant CFL.

Research

UPW AIoT System

Industrial AI research connecting simulation, data-driven prediction and control in ultrapure water production.

Next step

Assessing a similar problem?

Tell us the geometry, the operating conditions and the decision you need to make, and we will scope it with you.