hyperactor/runtime_identity.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//! Runtime-identity tagging: which Tokio runtime a thread belongs to.
10//!
11//! Monarch runs Python on more than one Tokio runtime: the shared control-plane
12//! runtime that drives `PythonActor` dispatch, and separate data-plane runtimes
13//! where GIL work runs safely, off the control plane (the tensor streams; the
14//! RDMA managers, during buffer registration). The
15//! hazard is contention on the shared runtime, where a thread holding the GIL
16//! while blocked stalls every actor loop, supervisor, and network task on it.
17//! So data-plane work runs on its own runtime, and control-plane GIL use is
18//! kept to brief, sanctioned sites. This module records a thread's
19//! [`RuntimeKind`] (stamped at thread start via `on_thread_start`, read via
20//! [`current_runtime_kind`]) so GIL-entry sites can tell the two apart. An
21//! unstamped thread is not owned by a Monarch runtime and reads as `None`.
22//!
23//! # Invariants (RI-*)
24//!
25//! - **RI-1 (unstamped-is-none):** [`current_runtime_kind`] returns `None` on
26//! any thread no runtime builder has tagged.
27//! - **RI-2 (tagging-is-thread-local-and-once):** [`tag_current_thread`] sets
28//! only the current OS thread's marker, and only once.
29//! - **RI-3 (data-plane-workers-tagged):** [`build_data_plane_runtime`] stamps
30//! every worker thread of the runtime it returns `DataPlane(label)`, via the
31//! builder's `on_thread_start`.
32//! - **RI-4 (tagging-does-not-leak):** spawning work onto a tagged runtime does
33//! not change the spawning thread's observed kind.
34//! - **RI-5 (process-lifetime-runtime):** the handle from
35//! [`build_data_plane_runtime`] is kept alive by the `DATA_PLANE_RUNTIMES`
36//! registry and stays valid until the runtime is torn down by
37//! [`shutdown_data_plane_runtimes`] at process teardown; callers must not spawn
38//! onto it afterward.
39//! - **RI-6 (block-on-host-tagged):** a data-plane runtime hosted on a manually
40//! spawned thread that drives its work via `rt.block_on` must tag that hosting
41//! thread explicitly; `on_thread_start` stamps only the runtime's own worker
42//! threads, not the `block_on` caller.
43//! - **RI-7 (teardown-registered):** every runtime from
44//! [`build_data_plane_runtime`] is registered in `DATA_PLANE_RUNTIMES` and shut
45//! down by [`shutdown_data_plane_runtimes`]. The pyo3 layer calls that at
46//! Python teardown before `Py_Finalize`; this crate stays Python-free.
47
48use std::cell::OnceCell;
49use std::sync::Mutex;
50use std::time::Duration;
51
52/// Which kind of Tokio runtime a thread belongs to.
53#[derive(Clone, Copy, PartialEq, Eq, Debug)]
54pub enum RuntimeKind {
55 /// The shared control-plane runtime that drives actor dispatch.
56 /// Control-plane GIL use is kept to brief, sanctioned sites.
57 ControlPlane,
58 /// A runtime off the control plane. GIL work here is safe because it does
59 /// not drive actor dispatch (the tensor streams; the RDMA managers during
60 /// buffer registration).
61 DataPlane(&'static str),
62}
63
64thread_local! {
65 // A thread is unstamped until a runtime builder's `on_thread_start` tags it,
66 // so non-runtime threads (e.g. Python-owned asyncio threads) read as `None`.
67 static KIND: OnceCell<RuntimeKind> = const { OnceCell::new() };
68}
69
70/// Stamp the current thread with `kind`. Call from a runtime builder's
71/// `on_thread_start` so every worker of that runtime carries the marker.
72pub fn tag_current_thread(kind: RuntimeKind) {
73 KIND.with(|k| k.set(kind).expect("runtime kind already tagged"));
74}
75
76/// The [`RuntimeKind`] of the current thread, if it is stamped.
77pub fn current_runtime_kind() -> Option<RuntimeKind> {
78 KIND.with(|k| k.get().copied())
79}
80
81/// Data-plane runtimes built by [`build_data_plane_runtime`], retained so they
82/// stay alive and can be torn down at process teardown via
83/// [`shutdown_data_plane_runtimes`].
84static DATA_PLANE_RUNTIMES: Mutex<Vec<tokio::runtime::Runtime>> = Mutex::new(Vec::new());
85
86/// Build a dedicated data-plane runtime on its own OS thread, tagged
87/// `DataPlane(label)`, and return a handle for spawning onto it.
88///
89/// The runtime runs on a standalone `std::thread` rather than a Tokio
90/// `spawn_blocking` thread: a runtime-managed blocking thread is awaited at
91/// teardown, so uninterruptible FFI (CUDA, NCCL, ibverbs) running on it would
92/// hang shutdown forever. A standalone thread is not awaited. Its worker threads
93/// are stamped `DataPlane(label)` via `on_thread_start`. The returned
94/// [`Handle`](tokio::runtime::Handle) stays valid until the runtime (retained in
95/// `DATA_PLANE_RUNTIMES`) is torn down by [`shutdown_data_plane_runtimes`] at
96/// process teardown.
97pub fn build_data_plane_runtime(
98 label: &'static str,
99 worker_threads: usize,
100) -> tokio::runtime::Handle {
101 let (tx, rx) = std::sync::mpsc::channel();
102 std::thread::Builder::new()
103 .name(label.to_string())
104 .spawn(move || {
105 let rt = tokio::runtime::Builder::new_multi_thread()
106 .worker_threads(worker_threads)
107 .on_thread_start(move || tag_current_thread(RuntimeKind::DataPlane(label)))
108 .enable_all()
109 .build()
110 .expect("failed to build data-plane runtime");
111 let handle = rt.handle().clone();
112 // Stash the owned runtime so it stays alive (its worker threads keep
113 // running) and can be torn down at process teardown via
114 // shutdown_data_plane_runtimes; the builder thread then exits.
115 DATA_PLANE_RUNTIMES
116 .lock()
117 .expect("DATA_PLANE_RUNTIMES poisoned")
118 .push(rt);
119 tx.send(handle)
120 .expect("failed to hand back data-plane runtime handle");
121 })
122 .expect("failed to spawn data-plane runtime thread");
123 rx.recv()
124 .expect("failed to receive data-plane runtime handle")
125}
126
127/// Shut down every data-plane runtime built by [`build_data_plane_runtime`],
128/// waiting up to `timeout` for each. Idempotent: a second call finds an empty
129/// registry and does nothing. Pure Rust (no Python); the pyo3 layer must call
130/// this at Python teardown, before `Py_Finalize`, so data-plane workers stop
131/// before the interpreter is finalized.
132pub fn shutdown_data_plane_runtimes(timeout: Duration) {
133 // Drain under the lock, then shut down outside it (shutdown_timeout blocks).
134 let runtimes: Vec<tokio::runtime::Runtime> = DATA_PLANE_RUNTIMES
135 .lock()
136 .expect("DATA_PLANE_RUNTIMES poisoned")
137 .drain(..)
138 .collect();
139 shutdown_runtimes(runtimes, timeout);
140}
141
142fn shutdown_runtimes(runtimes: Vec<tokio::runtime::Runtime>, timeout: Duration) {
143 for rt in runtimes {
144 rt.shutdown_timeout(timeout);
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 // RI invariant coverage:
153 // RI-1 (unstamped-is-none): tag_and_read_round_trip; build_data_plane_runtime_tags_workers.
154 // RI-2 (tagging-is-thread-local-and-once): tag_and_read_round_trip;
155 // tag_current_thread_panics_when_retagged.
156 // RI-3 (data-plane-workers-tagged): build_data_plane_runtime_tags_workers.
157 // RI-4 (tagging-does-not-leak): build_data_plane_runtime_tags_workers.
158 // RI-5 (process-lifetime-runtime): structural (the registry keeps it alive;
159 // the tests rely on the runtime staying alive).
160 // RI-6 (block-on-host-tagged): on_thread_start_does_not_tag_the_block_on_thread.
161 // RI-7 (teardown-registered): shutdown_aborts_in_flight_task;
162 // shutdown_runtimes_empty_is_noop. (the global registry drain is a thin
163 // Vec::drain wrapper, exercised structurally.)
164
165 // RI-1/RI-2: a fresh thread is unstamped; tagging then reads back on that thread.
166 #[test]
167 fn tag_and_read_round_trip() {
168 // A fresh thread is unstamped.
169 assert_eq!(current_runtime_kind(), None);
170 tag_current_thread(RuntimeKind::ControlPlane);
171 assert_eq!(current_runtime_kind(), Some(RuntimeKind::ControlPlane));
172 }
173
174 #[test]
175 #[should_panic(expected = "runtime kind already tagged")]
176 fn tag_current_thread_panics_when_retagged() {
177 tag_current_thread(RuntimeKind::ControlPlane);
178 tag_current_thread(RuntimeKind::DataPlane("test-dp"));
179 }
180
181 // RI-3/RI-4: workers are stamped DataPlane(label) while the caller stays unstamped.
182 #[test]
183 fn build_data_plane_runtime_tags_workers() {
184 // The calling (test) thread is unstamped.
185 assert_eq!(current_runtime_kind(), None);
186
187 let handle = build_data_plane_runtime("test-dp", 1);
188 let (tx, rx) = std::sync::mpsc::channel();
189 handle.spawn(async move {
190 let _ = tx.send(current_runtime_kind());
191 });
192
193 // A task on the data-plane runtime observes its DataPlane stamp,
194 assert_eq!(rx.recv().unwrap(), Some(RuntimeKind::DataPlane("test-dp")));
195 // while the stamp stays confined to that runtime's worker threads.
196 assert_eq!(current_runtime_kind(), None);
197 }
198
199 // RI-6 (block-on-host-tagged): `rt.block_on` drives its future on the CALLING
200 // thread, which is not a runtime worker, so `on_thread_start` does not cover
201 // it. A thread that runs work via `block_on` (the StreamActor pattern) must
202 // tag itself explicitly; the `on_thread_start` tag alone leaves it unstamped.
203 #[test]
204 fn on_thread_start_does_not_tag_the_block_on_thread() {
205 let (tx, rx) = std::sync::mpsc::channel();
206 std::thread::spawn(move || {
207 let rt = tokio::runtime::Builder::new_multi_thread()
208 .worker_threads(1)
209 .on_thread_start(|| tag_current_thread(RuntimeKind::DataPlane("x")))
210 .enable_all()
211 .build()
212 .unwrap();
213 // block_on runs on this (the calling) thread, which on_thread_start
214 // does not tag -> still unstamped.
215 let on_caller = rt.block_on(async { current_runtime_kind() });
216 // a spawned task runs on a worker -> DataPlane.
217 let on_worker = rt.block_on(async {
218 tokio::spawn(async { current_runtime_kind() })
219 .await
220 .unwrap()
221 });
222 let _ = tx.send((on_caller, on_worker));
223 });
224 let (on_caller, on_worker) = rx.recv().unwrap();
225 assert_eq!(on_caller, None);
226 assert_eq!(on_worker, Some(RuntimeKind::DataPlane("x")));
227 }
228
229 // RI-7: shut down a runtime with an in-flight task, and confirm the task is
230 // aborted. Built locally (not via the shared registry) so the test is
231 // isolated. Race-safe: wait for the task to signal it is live and pending
232 // before shutting down.
233 #[test]
234 fn shutdown_aborts_in_flight_task() {
235 use std::sync::Arc;
236 use std::sync::atomic::AtomicBool;
237 use std::sync::atomic::Ordering;
238
239 let rt = tokio::runtime::Builder::new_multi_thread()
240 .worker_threads(1)
241 .enable_all()
242 .build()
243 .unwrap();
244 let (live_tx, live_rx) = std::sync::mpsc::channel();
245 let reached_end = Arc::new(AtomicBool::new(false));
246 let reached_end_task = reached_end.clone();
247 rt.spawn(async move {
248 let _ = live_tx.send(()); // signal live before the long await
249 tokio::time::sleep(Duration::from_secs(30)).await;
250 reached_end_task.store(true, Ordering::SeqCst); // only if not aborted
251 });
252 live_rx.recv().unwrap(); // task is live and pending at the await
253
254 shutdown_runtimes(vec![rt], Duration::from_millis(200));
255
256 assert!(!reached_end.load(Ordering::SeqCst));
257 }
258
259 // RI-7: shutting down an empty set is a no-op (the idempotent second call).
260 #[test]
261 fn shutdown_runtimes_empty_is_noop() {
262 shutdown_runtimes(vec![], Duration::from_secs(1));
263 }
264}