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}