Skip to main content

monarch_hyperactor/
pywaker.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//! Pywaker provides a GIL-free way to wake up Python event loops from Rust.
10//! This is accomplished by writing a single byte to a "self pipe" for every
11//! wake up. The corresponding read-side FD is added to the Python-side event
12//! loop.
13//!
14//! The approach does not add overhead in uncontended scenarios:
15//!
16//! ```ignore
17//!   | Approach             | Mean   | Notes                           |
18//!   |----------------------|--------|---------------------------------|
19//!   | FD-based             | ~48 µs | No GIL acquisition on Rust side |
20//!   | call_soon_threadsafe | ~56 µs | Acquires GIL on Rust side       |
21//!   | Pure Python          | ~44 µs | Baseline for comparison         |
22//! ```
23//!
24//! ([Full benchmarks](https://github.com/mariusae/tmp/tree/main/2025-12-16-wakerbench)).
25//!
26//! ## Implementation notes
27//!
28//! [`event`] creates a pipe(2), and wakeup events are implemented by writing a single
29//! byte into the pipe. The Python (read side) event registers the read fd into its
30//! asyncio event loop. Thus the pipe acts as a notification queue. When the fd becomes
31//! readable, the python event drains the pipe and then sets an `asyncio.Event` to notify
32//! any waiters. [`pipe`] supplies the same pipe to a reader that is not an event loop,
33//! such as a sync actor's inbox, which waits on the read fd with `poll`.
34//!
35//! Each read side has a single reader thread, and is thus free of race conditions: an
36//! event's reads all occur within its asyncio event loop, which processes events
37//! sequentially on one thread, and a sync actor's inbox is read only by its driver.
38
39use std::os::fd::FromRawFd;
40use std::os::fd::IntoRawFd;
41use std::os::fd::OwnedFd;
42use std::os::fd::RawFd;
43
44use monarch_types::MapPyErr;
45use nix::errno::Errno;
46use nix::fcntl::FcntlArg;
47use nix::fcntl::OFlag;
48use nix::fcntl::fcntl;
49use nix::unistd;
50use nix::unistd::write;
51use pyo3::Bound;
52use pyo3::Py;
53use pyo3::PyAny;
54use pyo3::PyResult;
55use pyo3::prelude::*;
56use pyo3::pyclass;
57use pyo3::types::PyModule;
58use pyo3::types::PyModuleMethods;
59
60/// Write side of a non-blocking self-pipe used to notify its receiver.
61#[derive(Debug)]
62pub struct Waker {
63    /// Pipe descriptor written by [`Waker::wake`]. The corresponding read end
64    /// is owned by a [`PyEvent`] or a blocking Python receiver.
65    write_fd: OwnedFd,
66}
67
68impl Waker {
69    /// Notify the receiver by writing one byte to the pipe.
70    ///
71    /// A full pipe already represents a pending notification and therefore
72    /// counts as success. A closed read end returns `false`.
73    pub fn wake(&self) -> Result<bool, nix::Error> {
74        static DATA: [u8; 1] = *b"w";
75
76        match retry_eintr(|| write(&self.write_fd, &DATA)) {
77            Ok(_) => Ok(true),
78            // Pipe is full. This is ok.
79            Err(Errno::EAGAIN) => Ok(true),
80            // The Python side closed the pipe. It is no longer listening.
81            Err(Errno::EPIPE) => Ok(false),
82            Err(e) => Err(e),
83        }
84    }
85}
86
87/// Wakers are not intended to be used from Python; TestWaker
88/// is provided to faciliate pure-Python unit testing.
89// NOTE: We can't use a Python calss name that starts with "Test" since
90// during Python testing, Pytest will inspect anything that starts with
91// "Test" and check if its callable which in pyo3 >= 0.26 will raise
92// a TypeError.
93#[pyclass(name = "PyTestWaker", module = "monarch._src.actor.waker")]
94struct TestPyWaker(Waker);
95
96#[pymethods]
97impl TestPyWaker {
98    fn wake(&self) -> PyResult<bool> {
99        self.0.wake().map_pyerr()
100    }
101
102    #[staticmethod]
103    fn create() -> PyResult<(TestPyWaker, PyEvent)> {
104        let (waker, event) = event().map_pyerr()?;
105        Ok((TestPyWaker(waker), event))
106    }
107}
108
109/// An event that is awoken by a [`Waker`].
110#[pyclass(name = "Event", module = "monarch._src.actor.waker")]
111pub struct PyEvent {
112    #[pyo3(get, name = "_read_fd")]
113    read_fd: RawFd,
114
115    #[pyo3(get, set, name = "_event_loop")]
116    event_loop: Option<Py<PyAny>>,
117
118    #[pyo3(get, set, name = "_event")]
119    event: Option<Py<PyAny>>,
120}
121
122impl Drop for PyEvent {
123    fn drop(&mut self) {
124        // SAFETY: fd was obtained via into_raw_fd() in the event() function
125        let _ = unsafe { OwnedFd::from_raw_fd(self.read_fd) };
126    }
127}
128
129/// Create a new event, returning the (Rust only) [`Waker`], and
130/// a Python [`PyEvent`], intended for passing to Python code.
131pub fn event() -> Result<(Waker, PyEvent), nix::Error> {
132    let (waker, read_fd) = pipe()?;
133
134    Ok((
135        waker,
136        PyEvent {
137            read_fd: read_fd.into_raw_fd(),
138            event_loop: None,
139            event: None,
140        },
141    ))
142}
143
144/// Create a non-blocking pipe, returning its write end as a [`Waker`] and
145/// its read end for the caller to wait on.
146pub(crate) fn pipe() -> Result<(Waker, OwnedFd), nix::Error> {
147    let (read_fd, write_fd) = unistd::pipe()?;
148
149    set_nonblocking(&read_fd)?;
150    set_nonblocking(&write_fd)?;
151
152    Ok((Waker { write_fd }, read_fd))
153}
154
155/// Call `f` again for as long as it fails with `EINTR`.
156pub(crate) fn retry_eintr<T>(mut f: impl FnMut() -> nix::Result<T>) -> nix::Result<T> {
157    loop {
158        match f() {
159            Err(Errno::EINTR) => continue,
160            result => return result,
161        }
162    }
163}
164
165fn set_nonblocking(fd: &OwnedFd) -> Result<(), nix::Error> {
166    let flags = OFlag::from_bits_truncate(fcntl(fd, FcntlArg::F_GETFL)?) | OFlag::O_NONBLOCK;
167    fcntl(fd, FcntlArg::F_SETFL(flags))?;
168    Ok(())
169}
170
171pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
172    hyperactor_mod.add_class::<PyEvent>()?;
173    hyperactor_mod.add_class::<TestPyWaker>()?;
174    Ok(())
175}