Skip to content

Rebuild the Python runtime after fork - #9376

Open
robert3005 wants to merge 1 commit into
rk/python-expr-apifrom
rk/python-fork-runtime
Open

Rebuild the Python runtime after fork#9376
robert3005 wants to merge 1 commit into
rk/python-expr-apifrom
rk/python-fork-runtime

Conversation

@robert3005

Copy link
Copy Markdown
Contributor

Make python runtime pool fork friendly. While it's preferable to use threads
instead of processes we should gracefully recover from being forked instead of
deadlocking


Stack created with GitHub Stacks CLIGive Feedback 💬

`fork(2)` copies only the calling thread, so a forked child inherits a
`CurrentThreadRuntime` whose worker threads no longer exist: the executor's
sleeper list and the pool's handle list describe phantom threads, so
`set_workers` believes it already has enough workers and spawns none, and every
Vortex operation in the child blocks forever. The same applies to the
process-global `blocking` pool behind `spawn_blocking`, whose inherited
`idle_count` keeps it from growing.

Tag the runtime with its owning pid and build a fresh one on first use from a
different process, rather than trying to repair the inherited state; the stale
state is leaked deliberately, since running its destructors would take locks
that may not have survived the fork. An `os.register_at_fork(after_in_child=)`
handler does the rebuild on the child's single-threaded startup path and warms
the blocking pool. The shared session is repointed at the new executor, and the
requested worker count is held outside the runtime so a child inherits it.

`VortexFile` gains `path` and `__reduce__`, reopening by path in the receiving
process, and the `datasets` filter path no longer needs its in-memory fallback
now that a filter can be pickled into `num_proc` workers.

Signed-off-by: Robert Kruszewski <github@robertk.io>
@robert3005 robert3005 added the changelog/fix A bug fix label Aug 12, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 12, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 5.65%

⚡ 2 improved benchmarks
❌ 3 regressed benchmarks
✅ 1957 untouched benchmarks
⏩ 89 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation decompress[u64, (10000, 4)] 310.8 µs 402.7 µs -22.82%
Simulation decompress[u64, (1000, 16)] 64.2 µs 73 µs -12.07%
Simulation take[small_m/shuffled/primitive/nonnull/chunks=16384/indices=16] 1.1 ms 1.2 ms -11.64%
Simulation slice_primitive_tight_loop[10000] 473.9 µs 423.8 µs +11.81%
Simulation slice_dict_tight_loop[10000] 770.3 µs 690.9 µs +11.5%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing rk/python-fork-runtime (0f621f8) with develop (d4b5d24)2

Open in CodSpeed

Footnotes

  1. 89 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on rk/python-expr-api (9871191) during the generation of this report, so develop (d4b5d24) was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Comment thread vortex-python/src/lib.rs
}
}

static RUNTIME_STATE: RwLock<Option<RuntimeState>> = RwLock::new(None);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be thread local?

@onursatici onursatici left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea but I think it is still possible to be blocked.

In general for this to work we should:

  • get the current_pid == state_pid check done without holding any locks, because that very lock could be stuck locked in fork
  • when re-initialising the runtime state, we should make sure we are creating everything new, currently the internal Executor from the current thread runtime is copied and has an inner lock

Comment thread vortex-python/src/lib.rs
/// Force the process-global blocking-IO thread pool to spawn at least one live thread.
///
/// Vortex reads route through `Handle::spawn_blocking`, which is backed by the `blocking` crate's
/// process-global pool. That pool only grows while `queue.len() > idle_count * 5`, and a forked

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the condition is:

while inner.queue().len() > inner.idle_count * 5
    && inner.thread_count < thread_limit

which make this flaky, if thread_count was at the limit at the time of the fork, there is no way we can make this executor create new threads because there is no threads in the fork that would reduce the thread_count number when they die, so it is stuck

Comment thread vortex-python/src/lib.rs
})?;

// `fork(2)` leaves the inherited Vortex runtime unusable, so rebuild it in the child.
register_at_fork(py)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we gate this with unix? I think having this unconditional here makes vortex not usable on windows

Comment thread vortex-python/src/lib.rs
}
}

static RUNTIME_STATE: RwLock<Option<RuntimeState>> = RwLock::new(None);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is still prone to be forked while locked right? I can hold the read lock, get forked while holding it, in the new process there is no more readers but the read lock is held so it is not possible to get the write lock again

Comment thread vortex-python/src/lib.rs
/// The process that built this runtime.
pid: u32,
runtime: CurrentThreadRuntime,
pool: CurrentThreadWorkerPool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this pool has a reference to the Executor cloned from the runtime. This executor also has a lock inside. So even if we could make runtime state lock free, I think as long as we have a process global runtime, we share this same Executor everywhere. So if we fork while the executor is locked then the child process has no way to unlock

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants