Skip to main content

monarch_gil/
lib.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9#![deny(clippy::disallowed_methods)]
10
11//! Control-plane GIL accounting.
12//!
13//! The GIL wrappers (`monarch_with_gil` / `monarch_with_gil_blocking`) route every
14//! Python acquisition through `check_gil_site`, which flags any acquisition on the
15//! shared control-plane runtime whose `GilSite` is not sanctioned
16//! (`is_control_plane_allowed`). This makes "no unsanctioned Python on the
17//! control-plane runtime" a counted, debug-assertable invariant. Builds on the
18//! `RuntimeKind` tagging in `hyperactor::runtime_identity`.
19//!
20//! # Invariants (GIL-*)
21//!
22//! - **GIL-1 (outermost-entry-accountable):** `check_gil_site` acts only at the
23//!   outermost logical GIL entry (when `is_reentrant()` is false); a re-entrant
24//!   acquisition under a sanctioned entry is attributed to that entry, not checked
25//!   on its own.
26//! - **GIL-2 (off-control-plane-exempt):** `check_gil_site` is a no-op on
27//!   `DataPlane` and unstamped threads; only `RuntimeKind::ControlPlane` is policed.
28//! - **GIL-3 (allowlist-exhaustive):** `is_control_plane_allowed` is a wildcard-free
29//!   `match` over `GilSite`, so a new variant cannot compile until it is classified,
30//!   and `GilSite` has no catch-all variant.
31//! - **GIL-4 (unsanctioned-accounted):** an unsanctioned control-plane acquisition
32//!   increments `GIL_ON_CONTROL_PLANE`, logs the caller location, and trips a
33//!   `debug_assert` in debug builds.
34
35use std::sync::LazyLock;
36use std::sync::atomic::AtomicU64;
37use std::sync::atomic::Ordering;
38
39use hyperactor::runtime_identity::RuntimeKind;
40use hyperactor::runtime_identity::current_runtime_kind;
41use pyo3::Python;
42use pyo3::prelude::*;
43
44/// Global lock to serialize GIL acquisition from Rust threads in async contexts.
45///
46/// Under high concurrency, many async tasks can simultaneously try to acquire the GIL.
47/// Each call blocks the current tokio worker thread, which can cause runtime starvation
48/// and apparent deadlocks (nothing else gets polled).
49///
50/// This wrapper serializes GIL acquisition among callers that opt in, so at most one
51/// tokio task is blocked in `Python::attach` at a time, improving fairness under
52/// contention.
53///
54/// Note: this does not globally prevent other sync code from calling `Python::attach`
55/// directly. Use `monarch_with_gil` or `monarch_with_gil_blocking` for Python interaction
56/// that occurs on async hot paths.
57static GIL_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
58
59// Thread-local depth counter for re-entrant GIL acquisition.
60//
61// This tracks when we're already inside a `monarch_with_gil` or `monarch_with_gil_blocking`
62// call. On re-entry (e.g., when Python calls back into Rust while we're already executing
63// under `Python::attach`), we bypass the `GIL_LOCK` to avoid deadlocks.
64//
65// Without this, the following scenario would deadlock:
66// 1. Rust async code calls `monarch_with_gil`, acquires `GIL_LOCK`
67// 2. Inside the closure, Python code is executed
68// 3. Python code calls back into Rust (e.g., via a PyO3 callback)
69// 4. The callback tries to call `monarch_with_gil` again
70// 5. DEADLOCK: waiting for `GIL_LOCK` which is held by the same logical call chain
71thread_local! {
72    static GIL_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
73}
74
75/// RAII guard that decrements the GIL depth counter when dropped.
76struct GilDepthGuard {
77    prev_depth: u32,
78}
79
80impl Drop for GilDepthGuard {
81    fn drop(&mut self) {
82        GIL_DEPTH.with(|d| d.set(self.prev_depth));
83    }
84}
85
86/// Increments the GIL depth counter and returns a guard that restores it on drop.
87fn increment_gil_depth() -> GilDepthGuard {
88    let prev_depth = GIL_DEPTH.with(|d| {
89        let current = d.get();
90        d.set(current + 1);
91        current
92    });
93    GilDepthGuard { prev_depth }
94}
95
96/// Returns true if we're already inside a `monarch_with_gil` call (re-entrant).
97fn is_reentrant() -> bool {
98    GIL_DEPTH.with(|d| d.get() > 0)
99}
100
101/// A sanctioned GIL acquisition operation, identifying *why* a
102/// `monarch_with_gil`/`monarch_with_gil_blocking` site takes the GIL. Each
103/// variant names one distinct operation so `is_control_plane_allowed` can
104/// classify it. There is deliberately no catch-all: a new GIL site must add a
105/// variant and classify it, or the code will not compile.
106///
107/// This sanctioned set is transitional and meant to shrink: the goal is to move
108/// Python off the control-plane runtime entirely, retiring these operations over
109/// time, so the allow-list (and this enum) trends toward empty. New variants
110/// should be rare and short-lived.
111#[derive(Clone, Copy, PartialEq, Eq, Debug)]
112pub enum GilSite {
113    /// Call into a Python actor from Rust: FUSE and NFS mounts, and telemetry
114    /// queries.
115    EndpointDispatch,
116    /// Start the Python dispatch loop during actor init.
117    DispatchInit,
118    /// Run an actor's `__cleanup__`.
119    EndpointCleanup,
120    /// Run `__supervise__`, or inspect a supervision result.
121    Supervise,
122    /// Render an actor's display name.
123    DisplayName,
124    /// Construct a `PythonActor` (unpickle the actor type).
125    ActorConstruct,
126    /// Bootstrap a client actor, or convert bootstrap addresses.
127    Bootstrap,
128    /// Create or clone a `TaskLocals` (asyncio event loop).
129    TaskLocals,
130    /// Convert a port reply into a Python value.
131    ReplyConvert,
132    /// Run a comm reducer.
133    Reducer,
134    /// Run an accumulator.
135    Accumulate,
136    /// Convert a Rust value into a Python value (or clone a Python value).
137    Convert,
138    /// Capture, format, or clone a Python traceback or exception.
139    Traceback,
140    /// Drive a Python coroutine or blocking task on the bridge.
141    AwaitDrive,
142    /// Cancel pending tasks, stop an event loop, or interrupt the client.
143    Stop,
144    /// Interactive debugger read/write.
145    Debugger,
146    /// Get or set a Python logger.
147    Logging,
148    /// Run the code-sync auto-reloader.
149    CodeSync,
150    /// Register or operate RDMA buffers (runs on the data plane).
151    Rdma,
152    /// Worker-process startup: torch import, env init. Runs on the
153    /// control-plane runtime (the `WorkerActor` has no data-plane override).
154    WorkerInit,
155    /// Tensor/Python work on the `StreamActor` data-plane runtime.
156    StreamCompute,
157    /// Test-only GIL use.
158    Test,
159}
160
161/// Counts GIL acquisitions for unsanctioned operations on the control-plane
162/// runtime. Exposed to Python via `get_gil_on_control_plane` for tests.
163static GIL_ON_CONTROL_PLANE: AtomicU64 = AtomicU64::new(0);
164
165/// Whether `site` is allowed to take the GIL on the control-plane runtime.
166///
167/// This `match` has no wildcard arm on purpose: adding a `GilSite` variant must
168/// fail to compile until it is classified here. `true` is the transitional
169/// sanctioned set of operations that run on the control plane today; `false`
170/// marks operations that must run elsewhere (the data plane).
171fn is_control_plane_allowed(site: GilSite) -> bool {
172    match site {
173        // Dispatch and reply.
174        GilSite::EndpointDispatch
175        | GilSite::DispatchInit
176        | GilSite::EndpointCleanup
177        | GilSite::ReplyConvert => true,
178        // Supervision and lifecycle.
179        GilSite::Supervise
180        | GilSite::DisplayName
181        | GilSite::ActorConstruct
182        | GilSite::Bootstrap
183        | GilSite::Stop => true,
184        // Accumulation and reduction.
185        GilSite::Reducer | GilSite::Accumulate => true,
186        // Coroutine driving and value conversion.
187        GilSite::TaskLocals | GilSite::Convert | GilSite::Traceback | GilSite::AwaitDrive => true,
188        // Auxiliary control-plane services.
189        GilSite::Debugger | GilSite::Logging | GilSite::CodeSync => true,
190        // Tests run their GIL work on control-plane-tagged threads.
191        GilSite::Test => true,
192        // Worker-process startup runs on the control-plane runtime (the
193        // WorkerActor has no data-plane override); sanctioned one-time setup.
194        GilSite::WorkerInit => true,
195        // Data-plane subsystems own a runtime and must never take the GIL on
196        // the control plane; a control-plane grab here is the regression this
197        // net catches.
198        GilSite::Rdma | GilSite::StreamCompute => false,
199    }
200}
201
202/// Account for a GIL acquisition at `site`. On the control-plane runtime, an
203/// unsanctioned `site` (per `is_control_plane_allowed`) bumps
204/// `GIL_ON_CONTROL_PLANE`, logs a warning, and trips a `debug_assert`. Only the
205/// outermost logical entry is checked; re-entrant acquisitions are skipped.
206#[track_caller]
207fn check_gil_site(site: GilSite) {
208    if is_reentrant() {
209        return;
210    }
211    if current_runtime_kind() != Some(RuntimeKind::ControlPlane) {
212        return;
213    }
214    if is_control_plane_allowed(site) {
215        return;
216    }
217    GIL_ON_CONTROL_PLANE.fetch_add(1, Ordering::Relaxed);
218    tracing::warn!(
219        site = ?site,
220        caller = %std::panic::Location::caller(),
221        "unsanctioned GIL on control-plane runtime"
222    );
223    debug_assert!(
224        false,
225        "unsanctioned GIL on control-plane: {:?} at {}",
226        site,
227        std::panic::Location::caller()
228    );
229}
230
231/// Async wrapper around `Python::attach` intended for async call sites.
232///
233/// Why: under high concurrency, many async tasks can simultaneously
234/// try to acquire the GIL. Each call blocks the current tokio worker
235/// thread, which can cause runtime starvation / apparent deadlocks
236/// (nothing else gets polled).
237///
238/// This wrapper serializes GIL acquisition among async callers so at most one tokio
239/// task is blocked in `Python::attach` at a time, preventing runtime starvation
240/// under GIL contention.
241///
242/// Note: this does not globally prevent other sync code from calling
243/// `Python::attach` directly, so the control-plane GIL accounting
244/// (`check_gil_site`) covers only acquisitions that route through these
245/// wrappers; a raw `Python::attach` elsewhere bypasses it. Use this wrapper
246/// for Python interaction that occurs on async hot paths.
247///
248/// # Re-entrancy Safety
249///
250/// This function is re-entrant safe. If called while already inside a `monarch_with_gil`
251/// or `monarch_with_gil_blocking` call (e.g., from a Python→Rust callback), it bypasses
252/// the `GIL_LOCK` to avoid deadlocks.
253///
254/// # Example
255/// ```ignore
256/// let result = monarch_with_gil(GilSite::Convert, |py| {
257///     // Do work with Python GIL
258///     Ok(42)
259/// })
260/// .await?;
261/// ```
262// No `#[track_caller]` (unlike the blocking wrapper): it does not propagate through
263// `async fn`, so the logged `GilSite` is the diagnostic for async sites.
264#[allow(clippy::disallowed_methods)]
265pub async fn monarch_with_gil<F, R>(site: GilSite, f: F) -> R
266where
267    F: for<'py> FnOnce(Python<'py>) -> R + Send,
268{
269    check_gil_site(site);
270
271    // If we're already inside a monarch_with_gil call (re-entrant), skip the lock
272    // to avoid deadlock from Python→Rust callbacks
273    if is_reentrant() {
274        let _depth_guard = increment_gil_depth();
275        return Python::attach(f);
276    }
277
278    // Not re-entrant: acquire the serialization lock
279    let _lock_guard = GIL_LOCK.lock().await;
280    let _depth_guard = increment_gil_depth();
281    Python::attach(f)
282}
283
284/// Blocking wrapper around `Python::with_gil` for use in synchronous contexts.
285///
286/// Unlike `monarch_with_gil`, this function does NOT use the `GIL_LOCK` async mutex.
287/// Since it is blocking call, it simply acquires the GIL and releases it when the
288/// closure returns.
289///
290/// Note: like `monarch_with_gil`, the control-plane GIL accounting
291/// (`check_gil_site`) covers only acquisitions that route through these wrappers;
292/// a raw `Python::attach` elsewhere bypasses it.
293///
294/// # Example
295/// ```ignore
296/// let result = monarch_with_gil_blocking(GilSite::Convert, |py| {
297///     // Do work with Python GIL
298///     Ok(42)
299/// })?;
300/// ```
301#[track_caller]
302#[allow(clippy::disallowed_methods)]
303pub fn monarch_with_gil_blocking<F, R>(site: GilSite, f: F) -> R
304where
305    // No `Send` bound (unlike `monarch_with_gil`): the closure runs on the
306    // current thread and never crosses a thread boundary.
307    F: for<'py> FnOnce(Python<'py>) -> R,
308{
309    check_gil_site(site);
310
311    let _depth_guard = increment_gil_depth();
312    Python::attach(f)
313}
314
315/// Number of unsanctioned GIL acquisitions seen on the control-plane runtime.
316/// For tests; see `GIL_ON_CONTROL_PLANE`.
317#[pyfunction]
318#[pyo3(name = "_get_gil_on_control_plane")]
319pub fn get_gil_on_control_plane() -> u64 {
320    GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed)
321}
322
323/// Reset the unsanctioned control-plane GIL counter to zero. For tests.
324#[pyfunction]
325#[pyo3(name = "_reset_gil_on_control_plane")]
326pub fn reset_gil_on_control_plane() {
327    GIL_ON_CONTROL_PLANE.store(0, Ordering::Relaxed);
328}
329
330/// Force one unsanctioned control-plane GIL acquisition and count it, for the
331/// negative fitness test. Runs on a freshly `ControlPlane`-tagged thread and
332/// routes through `check_gil_site` so the real gating is exercised; the
333/// debug-build `debug_assert` panic is swallowed so the increment sticks (in
334/// release builds there is no assert and the counter bumps directly).
335#[pyfunction]
336#[pyo3(name = "_force_unsanctioned_gil_on_control_plane")]
337pub fn force_unsanctioned_gil_on_control_plane() {
338    std::thread::spawn(|| {
339        hyperactor::runtime_identity::tag_current_thread(RuntimeKind::ControlPlane);
340        let _ = std::panic::catch_unwind(|| check_gil_site(GilSite::Rdma));
341    })
342    .join()
343    .unwrap();
344}
345
346#[cfg(test)]
347mod tests {
348    use hyperactor::runtime_identity::RuntimeKind;
349    use hyperactor::runtime_identity::tag_current_thread;
350
351    use super::*;
352
353    // GIL invariant coverage:
354    // GIL-1 (outermost-entry-accountable): reentrant_unsanctioned_is_skipped.
355    // GIL-2 (off-control-plane-exempt): unsanctioned_site_off_control_plane_is_silent.
356    // GIL-3 (allowlist-exhaustive): allowed_classification; structural (wildcard-free match, no catch-all).
357    // GIL-4 (unsanctioned-accounted): unsanctioned_site_on_control_plane_is_accounted; allowed_site_on_control_plane_is_silent.
358
359    // The classification is the source of truth for the net: dispatch is
360    // sanctioned; Rdma is not (it must run on the data plane).
361    // GIL-3:
362    #[test]
363    fn allowed_classification() {
364        assert!(is_control_plane_allowed(GilSite::EndpointDispatch));
365        assert!(is_control_plane_allowed(GilSite::Test));
366        assert!(!is_control_plane_allowed(GilSite::Rdma));
367    }
368
369    // An allowlisted site on a control-plane thread is a no-op: the counter does
370    // not move and `check_gil_site` does not trip the debug_assert. Run on a
371    // freshly tagged thread (the test thread itself is unstamped) and assert a
372    // delta so concurrent tests on the global counter do not interfere.
373    // GIL-4:
374    #[test]
375    fn allowed_site_on_control_plane_is_silent() {
376        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
377        std::thread::spawn(|| {
378            tag_current_thread(RuntimeKind::ControlPlane);
379            check_gil_site(GilSite::EndpointDispatch);
380        })
381        .join()
382        .unwrap();
383        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
384        assert_eq!(after, before, "allowed site must not bump the counter");
385    }
386
387    // An unsanctioned site (Rdma) on a control-plane thread is accounted: the
388    // counter bumps in `check_gil_site` before the debug_assert, in every build
389    // mode. `catch_unwind` absorbs the debug-build assert panic so the delta is
390    // observable regardless of `debug_assertions` (under `mode/opt`, which CI
391    // runs, the assert is compiled out and there is nothing to absorb). Run on a
392    // freshly tagged thread and assert a delta so concurrent tests on the global
393    // counter do not interfere.
394    // GIL-4:
395    #[test]
396    fn unsanctioned_site_on_control_plane_is_accounted() {
397        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
398        std::thread::spawn(|| {
399            tag_current_thread(RuntimeKind::ControlPlane);
400            let _ = std::panic::catch_unwind(|| check_gil_site(GilSite::Rdma));
401        })
402        .join()
403        .unwrap();
404        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
405        assert!(
406            after > before,
407            "unsanctioned control-plane GIL must bump the counter"
408        );
409    }
410
411    // Off the control plane the runtime-kind gate returns first, so even an
412    // unsanctioned site neither panics nor moves the counter.
413    // GIL-2:
414    #[test]
415    fn unsanctioned_site_off_control_plane_is_silent() {
416        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
417
418        // The test thread is unstamped.
419        check_gil_site(GilSite::Rdma);
420
421        // A DataPlane-tagged thread is likewise exempt.
422        std::thread::spawn(|| {
423            tag_current_thread(RuntimeKind::DataPlane("test-dp"));
424            check_gil_site(GilSite::Rdma);
425        })
426        .join()
427        .unwrap();
428
429        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
430        assert_eq!(after, before, "off control plane must not bump the counter");
431    }
432
433    // The reentrancy gate fires before the runtime-kind and allowlist checks: a
434    // re-entrant acquisition under a sanctioned entry is attributed to that
435    // entry, so an unsanctioned site under it is skipped, even on the control
436    // plane. Run on a freshly tagged thread so the depth guard is the only
437    // reason the check is skipped.
438    // GIL-1:
439    #[test]
440    fn reentrant_unsanctioned_is_skipped() {
441        std::thread::spawn(|| {
442            tag_current_thread(RuntimeKind::ControlPlane);
443            let _g = increment_gil_depth();
444            let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
445            check_gil_site(GilSite::Rdma);
446            let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
447            assert_eq!(after, before, "reentrant acquisition must not be checked");
448        })
449        .join()
450        .unwrap();
451    }
452}