Start worker processes from an injected context, not a global - #170
Merged
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
main()calledmp.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=Truewas load-bearing rather than defensive, because a default-context pool locks the global method to the platform default and the best-effort form then raisedRuntimeError, swallowed it, and let the pipeline run on fork.Both properties now come from an explicit context.
WorkflowOrchestratorownsmp_context = mp.get_context("spawn")and starts everyProcessandManagerfrom it, so the guarantee is local and what the global method happens to be is no longer any of its business.embedding.pyis the other spawn site, and it is easy to missNothing 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:
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. Withmain()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.pyemits 21DeprecationWarning: 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_batchsizepassed only because it forked. Spawn pickles the process target, and its_FakeProcesscloses 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.test_workflow.py+test_pipeline_e2e.py(same 32 tests)use of fork()deadlock warningsVerification
multiprocessing.process.BaseProcess, which is what a context'sProcessactually is;mp.Processonly ever named the default context's class.set_start_methodcall fails the "does not mutate the caller" test. Changing the orchestrator's context tomp.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
tautomeras the entry point it is, emptyingUPWARD_EXEMPTIONS. 8c — unifyingmain()andsmiles2mols— was dropped: they differ in contract, not executor, andsmiles2mols' refusals are audit finding M15.