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    /// Dispatch a message to a Python endpoint (direct dispatch).
114    EndpointDispatch,
115    /// Build a queued message for queue-dispatch mode.
116    QueueDispatch,
117    /// Start the Python dispatch loop during actor init.
118    DispatchInit,
119    /// Run an actor's `__cleanup__`.
120    EndpointCleanup,
121    /// Run `__supervise__`, or inspect a supervision result.
122    Supervise,
123    /// Render an actor's display name.
124    DisplayName,
125    /// Construct a `PythonActor` (unpickle the actor type).
126    ActorConstruct,
127    /// Bootstrap a client actor, or convert bootstrap addresses.
128    Bootstrap,
129    /// Create or clone a `TaskLocals` (asyncio event loop).
130    TaskLocals,
131    /// Convert a port reply into a Python value.
132    ReplyConvert,
133    /// Run a comm reducer.
134    Reducer,
135    /// Run an accumulator.
136    Accumulate,
137    /// Convert a Rust value into a Python value (or clone a Python value).
138    Convert,
139    /// Capture, format, or clone a Python traceback or exception.
140    Traceback,
141    /// Drive a Python coroutine or blocking task on the bridge.
142    AwaitDrive,
143    /// Cancel pending tasks, stop an event loop, or interrupt the client.
144    Stop,
145    /// Interactive debugger read/write.
146    Debugger,
147    /// Get or set a Python logger.
148    Logging,
149    /// Run the code-sync auto-reloader.
150    CodeSync,
151    /// Register or operate RDMA buffers (runs on the data plane).
152    Rdma,
153    /// Worker-process startup: torch import, env init. Runs on the
154    /// control-plane runtime (the `WorkerActor` has no data-plane override).
155    WorkerInit,
156    /// Tensor/Python work on the `StreamActor` data-plane runtime.
157    StreamCompute,
158    /// Test-only GIL use.
159    Test,
160}
161
162/// Counts GIL acquisitions for unsanctioned operations on the control-plane
163/// runtime. Exposed to Python via `get_gil_on_control_plane` for tests.
164static GIL_ON_CONTROL_PLANE: AtomicU64 = AtomicU64::new(0);
165
166/// Whether `site` is allowed to take the GIL on the control-plane runtime.
167///
168/// This `match` has no wildcard arm on purpose: adding a `GilSite` variant must
169/// fail to compile until it is classified here. `true` is the transitional
170/// sanctioned set of operations that run on the control plane today; `false`
171/// marks operations that must run elsewhere (the data plane).
172fn is_control_plane_allowed(site: GilSite) -> bool {
173    match site {
174        // Dispatch and reply.
175        GilSite::EndpointDispatch
176        | GilSite::QueueDispatch
177        | GilSite::DispatchInit
178        | GilSite::EndpointCleanup
179        | GilSite::ReplyConvert => true,
180        // Supervision and lifecycle.
181        GilSite::Supervise
182        | GilSite::DisplayName
183        | GilSite::ActorConstruct
184        | GilSite::Bootstrap
185        | GilSite::Stop => true,
186        // Accumulation and reduction.
187        GilSite::Reducer | GilSite::Accumulate => true,
188        // Coroutine driving and value conversion.
189        GilSite::TaskLocals | GilSite::Convert | GilSite::Traceback | GilSite::AwaitDrive => true,
190        // Auxiliary control-plane services.
191        GilSite::Debugger | GilSite::Logging | GilSite::CodeSync => true,
192        // Tests run their GIL work on control-plane-tagged threads.
193        GilSite::Test => true,
194        // Worker-process startup runs on the control-plane runtime (the
195        // WorkerActor has no data-plane override); sanctioned one-time setup.
196        GilSite::WorkerInit => true,
197        // Data-plane subsystems own a runtime and must never take the GIL on
198        // the control plane; a control-plane grab here is the regression this
199        // net catches.
200        GilSite::Rdma | GilSite::StreamCompute => false,
201    }
202}
203
204/// Account for a GIL acquisition at `site`. On the control-plane runtime, an
205/// unsanctioned `site` (per `is_control_plane_allowed`) bumps
206/// `GIL_ON_CONTROL_PLANE`, logs a warning, and trips a `debug_assert`. Only the
207/// outermost logical entry is checked; re-entrant acquisitions are skipped.
208#[track_caller]
209fn check_gil_site(site: GilSite) {
210    if is_reentrant() {
211        return;
212    }
213    if current_runtime_kind() != Some(RuntimeKind::ControlPlane) {
214        return;
215    }
216    if is_control_plane_allowed(site) {
217        return;
218    }
219    GIL_ON_CONTROL_PLANE.fetch_add(1, Ordering::Relaxed);
220    tracing::warn!(
221        site = ?site,
222        caller = %std::panic::Location::caller(),
223        "unsanctioned GIL on control-plane runtime"
224    );
225    debug_assert!(
226        false,
227        "unsanctioned GIL on control-plane: {:?} at {}",
228        site,
229        std::panic::Location::caller()
230    );
231}
232
233/// Async wrapper around `Python::attach` intended for async call sites.
234///
235/// Why: under high concurrency, many async tasks can simultaneously
236/// try to acquire the GIL. Each call blocks the current tokio worker
237/// thread, which can cause runtime starvation / apparent deadlocks
238/// (nothing else gets polled).
239///
240/// This wrapper serializes GIL acquisition among async callers so at most one tokio
241/// task is blocked in `Python::attach` at a time, preventing runtime starvation
242/// under GIL contention.
243///
244/// Note: this does not globally prevent other sync code from calling
245/// `Python::attach` directly, so the control-plane GIL accounting
246/// (`check_gil_site`) covers only acquisitions that route through these
247/// wrappers; a raw `Python::attach` elsewhere bypasses it. Use this wrapper
248/// for Python interaction that occurs on async hot paths.
249///
250/// # Re-entrancy Safety
251///
252/// This function is re-entrant safe. If called while already inside a `monarch_with_gil`
253/// or `monarch_with_gil_blocking` call (e.g., from a Python→Rust callback), it bypasses
254/// the `GIL_LOCK` to avoid deadlocks.
255///
256/// # Example
257/// ```ignore
258/// let result = monarch_with_gil(GilSite::Convert, |py| {
259///     // Do work with Python GIL
260///     Ok(42)
261/// })
262/// .await?;
263/// ```
264// No `#[track_caller]` (unlike the blocking wrapper): it does not propagate through
265// `async fn`, so the logged `GilSite` is the diagnostic for async sites.
266#[allow(clippy::disallowed_methods)]
267pub async fn monarch_with_gil<F, R>(site: GilSite, f: F) -> R
268where
269    F: for<'py> FnOnce(Python<'py>) -> R + Send,
270{
271    check_gil_site(site);
272
273    // If we're already inside a monarch_with_gil call (re-entrant), skip the lock
274    // to avoid deadlock from Python→Rust callbacks
275    if is_reentrant() {
276        let _depth_guard = increment_gil_depth();
277        return Python::attach(f);
278    }
279
280    // Not re-entrant: acquire the serialization lock
281    let _lock_guard = GIL_LOCK.lock().await;
282    let _depth_guard = increment_gil_depth();
283    Python::attach(f)
284}
285
286/// Blocking wrapper around `Python::with_gil` for use in synchronous contexts.
287///
288/// Unlike `monarch_with_gil`, this function does NOT use the `GIL_LOCK` async mutex.
289/// Since it is blocking call, it simply acquires the GIL and releases it when the
290/// closure returns.
291///
292/// Note: like `monarch_with_gil`, the control-plane GIL accounting
293/// (`check_gil_site`) covers only acquisitions that route through these wrappers;
294/// a raw `Python::attach` elsewhere bypasses it.
295///
296/// # Example
297/// ```ignore
298/// let result = monarch_with_gil_blocking(GilSite::Convert, |py| {
299///     // Do work with Python GIL
300///     Ok(42)
301/// })?;
302/// ```
303#[track_caller]
304#[allow(clippy::disallowed_methods)]
305pub fn monarch_with_gil_blocking<F, R>(site: GilSite, f: F) -> R
306where
307    // No `Send` bound (unlike `monarch_with_gil`): the closure runs on the
308    // current thread and never crosses a thread boundary.
309    F: for<'py> FnOnce(Python<'py>) -> R,
310{
311    check_gil_site(site);
312
313    let _depth_guard = increment_gil_depth();
314    Python::attach(f)
315}
316
317/// Number of unsanctioned GIL acquisitions seen on the control-plane runtime.
318/// For tests; see `GIL_ON_CONTROL_PLANE`.
319#[pyfunction]
320#[pyo3(name = "_get_gil_on_control_plane")]
321pub fn get_gil_on_control_plane() -> u64 {
322    GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed)
323}
324
325/// Reset the unsanctioned control-plane GIL counter to zero. For tests.
326#[pyfunction]
327#[pyo3(name = "_reset_gil_on_control_plane")]
328pub fn reset_gil_on_control_plane() {
329    GIL_ON_CONTROL_PLANE.store(0, Ordering::Relaxed);
330}
331
332/// Force one unsanctioned control-plane GIL acquisition and count it, for the
333/// negative fitness test. Runs on a freshly `ControlPlane`-tagged thread and
334/// routes through `check_gil_site` so the real gating is exercised; the
335/// debug-build `debug_assert` panic is swallowed so the increment sticks (in
336/// release builds there is no assert and the counter bumps directly).
337#[pyfunction]
338#[pyo3(name = "_force_unsanctioned_gil_on_control_plane")]
339pub fn force_unsanctioned_gil_on_control_plane() {
340    std::thread::spawn(|| {
341        hyperactor::runtime_identity::tag_current_thread(RuntimeKind::ControlPlane);
342        let _ = std::panic::catch_unwind(|| check_gil_site(GilSite::Rdma));
343    })
344    .join()
345    .unwrap();
346}
347
348#[cfg(test)]
349mod tests {
350    use hyperactor::runtime_identity::RuntimeKind;
351    use hyperactor::runtime_identity::tag_current_thread;
352
353    use super::*;
354
355    // GIL invariant coverage:
356    // GIL-1 (outermost-entry-accountable): reentrant_unsanctioned_is_skipped.
357    // GIL-2 (off-control-plane-exempt): unsanctioned_site_off_control_plane_is_silent.
358    // GIL-3 (allowlist-exhaustive): allowed_classification; structural (wildcard-free match, no catch-all).
359    // GIL-4 (unsanctioned-accounted): unsanctioned_site_on_control_plane_is_accounted; allowed_site_on_control_plane_is_silent.
360
361    // The classification is the source of truth for the net: dispatch is
362    // sanctioned; Rdma is not (it must run on the data plane).
363    // GIL-3:
364    #[test]
365    fn allowed_classification() {
366        assert!(is_control_plane_allowed(GilSite::EndpointDispatch));
367        assert!(is_control_plane_allowed(GilSite::Test));
368        assert!(!is_control_plane_allowed(GilSite::Rdma));
369    }
370
371    // An allowlisted site on a control-plane thread is a no-op: the counter does
372    // not move and `check_gil_site` does not trip the debug_assert. Run on a
373    // freshly tagged thread (the test thread itself is unstamped) and assert a
374    // delta so concurrent tests on the global counter do not interfere.
375    // GIL-4:
376    #[test]
377    fn allowed_site_on_control_plane_is_silent() {
378        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
379        std::thread::spawn(|| {
380            tag_current_thread(RuntimeKind::ControlPlane);
381            check_gil_site(GilSite::EndpointDispatch);
382        })
383        .join()
384        .unwrap();
385        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
386        assert_eq!(after, before, "allowed site must not bump the counter");
387    }
388
389    // An unsanctioned site (Rdma) on a control-plane thread is accounted: the
390    // counter bumps in `check_gil_site` before the debug_assert, in every build
391    // mode. `catch_unwind` absorbs the debug-build assert panic so the delta is
392    // observable regardless of `debug_assertions` (under `mode/opt`, which CI
393    // runs, the assert is compiled out and there is nothing to absorb). Run on a
394    // freshly tagged thread and assert a delta so concurrent tests on the global
395    // counter do not interfere.
396    // GIL-4:
397    #[test]
398    fn unsanctioned_site_on_control_plane_is_accounted() {
399        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
400        std::thread::spawn(|| {
401            tag_current_thread(RuntimeKind::ControlPlane);
402            let _ = std::panic::catch_unwind(|| check_gil_site(GilSite::Rdma));
403        })
404        .join()
405        .unwrap();
406        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
407        assert!(
408            after > before,
409            "unsanctioned control-plane GIL must bump the counter"
410        );
411    }
412
413    // Off the control plane the runtime-kind gate returns first, so even an
414    // unsanctioned site neither panics nor moves the counter.
415    // GIL-2:
416    #[test]
417    fn unsanctioned_site_off_control_plane_is_silent() {
418        let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
419
420        // The test thread is unstamped.
421        check_gil_site(GilSite::Rdma);
422
423        // A DataPlane-tagged thread is likewise exempt.
424        std::thread::spawn(|| {
425            tag_current_thread(RuntimeKind::DataPlane("test-dp"));
426            check_gil_site(GilSite::Rdma);
427        })
428        .join()
429        .unwrap();
430
431        let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
432        assert_eq!(after, before, "off control plane must not bump the counter");
433    }
434
435    // The reentrancy gate fires before the runtime-kind and allowlist checks: a
436    // re-entrant acquisition under a sanctioned entry is attributed to that
437    // entry, so an unsanctioned site under it is skipped, even on the control
438    // plane. Run on a freshly tagged thread so the depth guard is the only
439    // reason the check is skipped.
440    // GIL-1:
441    #[test]
442    fn reentrant_unsanctioned_is_skipped() {
443        std::thread::spawn(|| {
444            tag_current_thread(RuntimeKind::ControlPlane);
445            let _g = increment_gil_depth();
446            let before = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
447            check_gil_site(GilSite::Rdma);
448            let after = GIL_ON_CONTROL_PLANE.load(Ordering::Relaxed);
449            assert_eq!(after, before, "reentrant acquisition must not be checked");
450        })
451        .join()
452        .unwrap();
453    }
454}