Skip to content

Bound tensorization process-pool shutdown at interpreter exit - #855

Merged
bradhilton merged 2 commits into
mainfrom
fix/process-pool-shutdown
Sep 4, 2026
Merged

Bound tensorization process-pool shutdown at interpreter exit#855
bradhilton merged 2 commits into
mainfrom
fix/process-pool-shutdown

Conversation

@bradhilton

@bradhilton bradhilton commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • shut the shared tensorization ProcessPoolExecutor down explicitly at interpreter exit, before concurrent.futures joins its workers, so the join is bounded
  • idle workers still leave via the normal shutdown sentinel; workers busy with work nobody can consume anymore are terminated after a 5 s grace (then killed), so cancellation and interpreter shutdown never orphan children or wait on them indefinitely
  • resolve a wandb-artifact:/// checkpoint's tokenizer configuration once per process instead of making a W&B API request on every tokenization
  • keep the adaptive thread/process selection, worker reuse across calls, ordered results, source rebinding, and the thread fallback unchanged; experiment scripts still need no pool or cleanup code

Root cause of the shutdown hang

concurrent.futures.process._python_exit runs inside threading._shutdown() and joins the pool's manager thread. The manager only sends shutdown sentinels once pending_work_items is empty, so any worker still executing an item (for example a tensorization whose awaiting task was cancelled by SIGINT-driven cleanup) blocks interpreter exit for as long as that item runs. ART never shut the global pool down itself, so the stdlib owned the final join.

Reproduced with a worker holding one in-flight item at exit: on main the driver never exits (faulthandler: main thread in process._python_exitjoin, manager thread in wait_result_broken_or_wakeup); with this change the same script exits in 5.8 s with no surviving children. The idle-pool path (normal exit, cancellation, worker exception, SIGINT) already exited in ~2 s on main and still does. The exact item that kept the 046 workers busy was not captured (py-spy cannot attach on that host), so the fix bounds the join regardless of cause.

Checkpoint tokenizer configs

_artifact_config fetched the artifact from W&B on every call; only the base model name was cached, and only alias-independently. Experiment 046 passes model=<wandb-artifact checkpoint> to art.tensorize, so every trajectory tokenized in every worker paid a 0.2–0.5 s API round trip (measured against the 046 checkpoint artifact). The resolved _TokenizerConfig is now cached per model string (bounded, single-flight, exceptions not cached), and callers receive a copy because _tokenizer_config mutates the config when base_model is given. The metadata describes the checkpoint collection, so it is stable across versions and aliases, the same assumption the existing base-model cache already made.

Validation

  • new test_interpreter_exit_is_bounded_after_process_tokenization: real spawn pool, two art.tokenize calls reuse the same worker PIDs, then one never-finishing item plus a cancelled tokenization at exit; asserts exit code 0, exit within 30 s of the last result (observed ~2 s beyond the 1 s test grace), and no surviving worker PIDs. Against main the same test hits a 150 s timeout.
  • new test_process_exit_hook_runs_before_stdlib_joins_workers
  • new test_checkpoint_tokenizer_configs_are_resolved_once_per_process: one API call across repeated and concurrent resolutions, independent copies, base_model override does not leak into the cache
  • pytest tests/unit/trajectories/test_tokenize.py tests/unit/trajectories/test_parallel_tokenize.py (253 passed)
  • Ruff and ty clean for the changed files; full ty check src tests unchanged from main

🤖 Generated with Claude Code

bradhilton and others added 2 commits September 4, 2026 18:50
The shared spawn-based ProcessPoolExecutor behind art.tokenize/tensorize
was never shut down by ART, so concurrent.futures owned the final join
during threading._shutdown(). Its manager thread only sends the worker
shutdown sentinels once no work item is pending, so a worker still busy
with an item nobody can consume anymore (for example after SIGINT-driven
cleanup cancelled the awaiting task) blocked interpreter exit for as long
as that item ran, leaving drivers alive with idle-looking children.

Register a threading exit hook that runs before the stdlib join: release
the global pool, shut it down without waiting, give workers a short grace
period to leave via the sentinel, then terminate and finally kill any
survivor. Idle pools still exit through the normal sentinel path, worker
reuse during the process lifetime is unchanged, and the thread fallback
and broken-pool handling are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_artifact_config fetched the W&B artifact on every call, so tokenizing
with a wandb-artifact:/// model paid an API round trip per trajectory in
every tensorization worker; only the base model name was cached. Cache
the resolved _TokenizerConfig per model string (bounded, single-flight,
exceptions not cached) and hand callers a copy, since _tokenizer_config
adjusts the config when base_model is given. The metadata describes the
checkpoint collection and does not change between versions, the same
assumption the base-model cache already makes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bradhilton
bradhilton merged commit c4c720a into main Sep 4, 2026
7 checks passed
@bradhilton
bradhilton deleted the fix/process-pool-shutdown branch September 4, 2026 19:24
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