Skip to content

Make parallelism thread- and core-safe (bounded, no recursive spawn) - #17

Merged
engelberger merged 4 commits into
stagingfrom
dev_parallel_safety
Jun 7, 2026
Merged

engelberger merged 4 commits into
stagingfrom
dev_parallel_safety

Conversation

@engelberger

Copy link
Copy Markdown
Owner

Make the parallelism thread- and core-safe so it cannot recursively spawn work.

The problem

  • Nested parallel paths could over-subscribe the machine: a parallel directory
    batch whose structures each open their own mutation pool multiplied process
    counts, and each worker process could spin one BLAS/OpenMP thread per core, so
    total threads approached cores squared.

The fix

  • A single shared concurrency budget that every backend draws from: outer pools take
    their worker count and inner pools get the remaining per-worker share, so the total
    number of live workers never exceeds the physical core count.
  • Per-worker BLAS/OpenMP thread counts are pinned to one in spawned workers, so a
    process does not itself fan out across all cores.
  • Pools are created with the budget and always closed/joined, including on error, so
    no orphan LAMMPS processes leak.

Verification

  • A test samples the live descendant process/thread count during a nested run and
    asserts it stays within the budget; a test checks clean shutdown with no leftover
    children. Existing outputs stay byte-identical.

Docs

  • A "parallelism and resource limits" note describing the shared budget, the thread
    pinning, and how the knobs compose.

Base branch is staging so this shows only the parallelism-safety diff.

Map of every process pool, in-worker subprocess, and backend that spawns
work, how each is sized, and the exact nesting call chains (batch x
singleresidue, evolution, direct singleresidue, trajectory).

Findings that drive the bounding work:
- Process counts on live paths are bounded to cores only by convention:
  the outer pool threads a reduced n_cpus/inner_cpus into the inner pool;
  there is no single shared budget enforcing it.
- No BLAS/OpenMP thread pinning anywhere, so cores^2 native threads are
  reachable on every path.
- Pools use the default fork start method, unsafe after threads start.
…fork-safe pools

Bound every parallel path so nested pools can never multiply into a fork
bomb that takes down the machine. Three changes, all drawing from one new
shared-budget module (utils/concurrency.py):

- Single core budget. resolve_concurrency(n_procs, n_items) splits the
  physical-core budget into (outer, inner) with outer * inner <= cores, and
  resolve_pool_size() caps a flat pool the same way. The batch executor
  (dir_frustration), the mutation scan pool (mutate_res_scan_parallel), and
  the evolution precompute pool all call these instead of each invoking
  cpu_count() independently, so no path can size a pool against full cores
  while already inside another pool.

- Per-worker native-thread pinning. apply_thread_limits() pins
  OMP/OPENBLAS/MKL/NUMEXPR/VECLIB/BLIS thread counts to 1 (via setdefault,
  so an explicit operator value wins). It runs at the very top of
  frustrapy/__init__.py from a dependency-free leaf module (_threadlimits.py),
  before any numpy/BLAS import, so the parent and every spawn/fork worker
  start single-threaded. Without this, cores worker processes could each spin
  cores BLAS threads (cores**2).

- Fork-safe start method. Pools are built from get_pool_context()
  (forkserver, else spawn, else default) with a worker initializer that
  re-pins threads, avoiding the fork-after-threads deadlock and orphaned
  LAMMPS children. close()/join() already run in finally on every path.

Default behaviour is unchanged: dir_frustration with no n_procs stays
serial. Verified output is byte-identical: 92 tests pass, including the
1CRN all-three-modes parity gate and the FrustraEvo byte-identical gate.
Add running tests that exercise the nested fork-bomb path rather than only the
sizing math. A dir_frustration(n_procs=2) singleresidue batch runs the
two-level pool nest while a sampler thread watches the live lmp_serial
descendant count, asserting the peak stays within the shared core budget; a
second test drives a pool from the shared fork-safe context with a raising
worker and confirms close()/join() leave no orphan processes.

Observed peak on a 14-core box is 7 concurrent lmp_serial (well under budget);
an unbounded version stacking two cpu_count() pools would reach ~28. Full suite
green (94 passed), 1CRN all 3 modes and FrustraEvo outputs byte-identical.
@engelberger
engelberger merged commit 9f4ce36 into staging Jun 7, 2026
3 of 5 checks passed
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.

1 participant