Skip to content

Start worker processes from an injected context, not a global - #170

Merged
isayev merged 1 commit into
mainfrom
refactor/injected-mp-context
Aug 18, 2026
Merged

isayev merged 1 commit into
mainfrom
refactor/injected-mp-context

Conversation

@isayev

@isayev isayev commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What

main() called mp.set_start_method("spawn", force=True) — a library reconfiguring its caller's interpreter. import Auto3D; main(...) changed the default start method for every other pool in the host program, for the rest of the process's life, permanently and without asking.

The requirement it served is real: the workers run PyTorch, and forking a process that has already initialized a CUDA context yields a broken context in the child — the worker crashes and the run produces no output, surfacing as "no 3D structure converged". force=True was load-bearing rather than defensive, because a default-context pool locks the global method to the platform default and the best-effort form then raised RuntimeError, swallowed it, and let the pipeline run on fork.

Both properties now come from an explicit context. WorkflowOrchestrator owns mp_context = mp.get_context("spawn") and starts every Process and Manager from it, so the guarantee is local and what the global method happens to be is no longer any of its business.

embedding.py is the other spawn site, and it is easy to miss

Nothing in it touches CUDA — it is RDKit work — so it does not need spawn for its own sake. It needs an explicit context because of how spawn propagates:

A spawned child inherits the parent's global start method. CPython puts start_method in the spawn preparation data and the child re-applies it with force=True.

Verified with a real subprocess rather than from memory. So under the old code: main() forced spawn → the isomer worker inherited spawn → the embedding pool inside it spawned too. With main() no longer setting a global, that child would inherit the host default and the pool would have started forking — in a process that has already imported torch. That is the site that would have flipped silently.

Python was already objecting to it. On main, tests/test_parallel_embed.py emits 21 DeprecationWarning: use of fork() may lead to deadlocks, because forking a multi-threaded process is hazardous and RDKit/numpy hold threads. On this branch there are zero across the whole suite: 40 warnings → 17.

One test changed, and it was not a broken patch target

test_run_pipeline_does_not_mutate_shared_batchsize passed only because it forked. Spawn pickles the process target, and its _FakeProcess closes over a list, so it could never have crossed a spawn boundary. It had been testing fork behavior in a pipeline whose whole point is that it must never fork.

Its assertions are unchanged; only the seam moved — from patching a module global to substituting the orchestrator's context. That is the change working as intended, since the start method is no longer reachable through global state.

The global mutation was also a performance leak

It was permanent and process-wide, so every unrelated pool in the same interpreter paid spawn's cost once main() had run. The deleted test's own comment warned about this — "would leak spawn into the rest of the session ... and run much slower" — and restored the method by hand for exactly that reason.

main this branch
full suite ~155s 145.04s
test_workflow.py + test_pipeline_e2e.py (same 32 tests) 56.68s 21.97s
use of fork() deadlock warnings 21 0

Verification

  • 1769 passed, 1 skipped, 74 deselected (+2 net new), randomized order.
  • mypy: 68 errors in 22 files, 75 checked — unchanged. Process annotations moved to multiprocessing.process.BaseProcess, which is what a context's Process actually is; mp.Process only ever named the default context's class.
  • Mutation-tested both guards. Restoring the global set_start_method call fails the "does not mutate the caller" test. Changing the orchestrator's context to mp.get_context() — reading the global instead of owning spawn — fails the "workers get spawn even when fork is locked in" test, which is the proof that the context is what provides the guarantee.

Context

This is wave 8a, and it completes wave 8. 8b (PR #169) refiled tautomer as the entry point it is, emptying UPWARD_EXEMPTIONS. 8c — unifying main() and smiles2mols — was dropped: they differ in contract, not executor, and smiles2mols' refusals are audit finding M15.

`main()` called `mp.set_start_method("spawn", force=True)`: a library
reconfiguring its caller's interpreter. `import Auto3D; main(...)` changed
the default start method for every other pool in the host program, for the
rest of the process's life, permanently and without asking.

The requirement it served is real. The workers run PyTorch, and forking a
process that has already initialized a CUDA context yields a broken context
in the child -- the worker crashes and the run produces no output,
surfacing as "no 3D structure converged". `force=True` was load-bearing
rather than defensive: a default-context pool locks the global method to
the platform default, and the best-effort form then raised RuntimeError,
swallowed it, and let the pipeline run on fork.

Both properties now come from an explicit context. `WorkflowOrchestrator`
owns `mp_context = mp.get_context("spawn")` and starts every Process and
Manager from it, so the guarantee is local and what the global method
happens to be is no longer any of its business.

`embedding.py` is the other spawn site and it is easy to miss. Nothing in
it touches CUDA -- it is RDKit work -- so it does not need spawn for its
own sake. It needs an explicit context because of how spawn propagates:
a spawned child inherits the parent's GLOBAL start method (CPython puts
`start_method` in the spawn preparation data and the child re-applies it
with force=True; verified with a real subprocess rather than from memory).
So under the old code, `main()` forced spawn -> the isomer worker inherited
spawn -> the embedding pool inside it spawned too. With `main()` no longer
setting a global, that child would inherit the host default and the pool
would have started forking -- in a process that has already imported torch.
That is the site that would have flipped silently.

Python was already objecting to it. On main, tests/test_parallel_embed.py
emits 21 `DeprecationWarning: use of fork() may lead to deadlocks`, because
forking a multi-threaded process is hazardous and RDKit/numpy hold threads.
On this branch there are zero across the whole suite: 40 warnings -> 17.

One test changed and it was not a broken patch target.
`test_run_pipeline_does_not_mutate_shared_batchsize` passed only because it
forked: spawn pickles the process target, and its `_FakeProcess` closes over
a list, so it could never have crossed a spawn boundary. It had been testing
fork behavior in a pipeline whose whole point is that it must never fork. Its
assertions are unchanged; only the seam moved, from patching a module global
to substituting the orchestrator's context -- which is the change working as
intended, since the start method is no longer reachable through global state.

Removing the global mutation also removes a leak that was slowing everything
after it. The old call was permanent and process-wide, so every unrelated
pool in the same interpreter paid spawn's cost once `main()` had run. The
deleted test's own comment warned about this ("would leak spawn into the
rest of the session ... and run much slower"); it restored the method by
hand for exactly that reason.

Verification:
- 1769 passed, 1 skipped, 74 deselected (+2 net new), randomized order,
  145.04s against ~155s on main.
- test_workflow.py + test_pipeline_e2e.py: 21.97s here, 56.68s on main.
  Same 32 tests. That is the leak, measured.
- mypy: 68 errors in 22 files, 75 checked -- unchanged. Process annotations
  moved to multiprocessing.process.BaseProcess, which is what a context's
  Process actually is; `mp.Process` named only the default context's class.
- Mutation-tested both guards. Restoring the global set_start_method call
  fails the "does not mutate the caller" test. Changing the orchestrator's
  context to `mp.get_context()` -- reading the global instead of owning
  spawn -- fails the "workers get spawn even when fork is locked in" test,
  which is the proof that the context is what provides the guarantee.
@isayev
isayev merged commit 73213a2 into main Aug 18, 2026
8 checks passed
@isayev
isayev deleted the refactor/injected-mp-context branch August 18, 2026 19:43
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