Skip to main content

monarch_hyperactor/
buffers.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#![allow(unsafe_op_in_unsafe_fn)]
10
11use std::ffi::c_int;
12use std::ffi::c_void;
13
14use bytes::Buf;
15use bytes::Bytes;
16use bytes::BytesMut;
17use hyperactor_config::CONFIG;
18use hyperactor_config::ConfigAttr;
19use hyperactor_config::attrs::declare_attrs;
20use pyo3::buffer::PyBuffer;
21use pyo3::prelude::*;
22use pyo3::types::PyBytes;
23use pyo3::types::PyBytesMethods;
24use serde::Deserialize;
25use serde::Serialize;
26use serde_multipart::Part;
27use typeuri::Named;
28
29use crate::runtime::GilSite;
30use crate::runtime::monarch_with_gil_blocking;
31
32declare_attrs! {
33    /// Threshold below which writes are copied into a contiguous buffer.
34    /// Writes >= this size are stored as zero-copy references.
35    @meta(CONFIG = ConfigAttr::new(
36        Some("MONARCH_HYPERACTOR_SMALL_WRITE_THRESHOLD".to_string()),
37        Some("small_write_threshold".to_string()),
38    ))
39    pub attr SMALL_WRITE_THRESHOLD: usize = 256;
40}
41
42/// Wrapper that keeps Py<PyBytes> alive while allowing zero-copy access to its memory
43struct KeepPyBytesAlive {
44    _py_bytes: Py<PyBytes>,
45    ptr: *const u8,
46    len: usize,
47}
48
49impl KeepPyBytesAlive {
50    fn new(py_bytes: Py<PyBytes>) -> Self {
51        let (ptr, len) = monarch_with_gil_blocking(GilSite::Convert, |py| {
52            let bytes_ref = py_bytes.as_bytes(py);
53            (bytes_ref.as_ptr(), bytes_ref.len())
54        });
55        Self {
56            _py_bytes: py_bytes,
57            ptr,
58            len,
59        }
60    }
61}
62
63impl AsRef<[u8]> for KeepPyBytesAlive {
64    fn as_ref(&self) -> &[u8] {
65        // SAFETY: ptr is valid as long as py_bytes is alive (kept alive by Py<PyBytes>)
66        // Python won't free the memory until the Py<PyBytes> refcount reaches 0
67        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
68    }
69}
70
71// SAFETY: Py<PyBytes> is Send/Sync for immutable bytes
72unsafe impl Send for KeepPyBytesAlive {}
73// SAFETY: Py<PyBytes> is Send/Sync for immutable bytes
74unsafe impl Sync for KeepPyBytesAlive {}
75
76/// A fragment of data in the buffer, either a copy or a reference.
77#[derive(Clone)]
78enum Fragment {
79    /// Small writes that were copied into a contiguous buffer
80    Copy(Bytes),
81    /// Large writes stored as references to Python bytes
82    Reference(Py<PyBytes>),
83}
84
85/// A mutable buffer for reading and writing bytes data.
86///
87/// The `Buffer` struct provides a hybrid interface for accumulating byte data:
88/// - Small writes (< 256 bytes) are copied into a contiguous buffer to minimize fragment overhead
89/// - Large writes (>= 256 bytes) are stored as zero-copy references to Python bytes objects
90///
91/// This approach balances the overhead of per-fragment processing against the cost of copying data.
92///
93/// # Examples
94///
95/// ```python
96/// from monarch._rust_bindings.monarch_hyperactor.buffers import Buffer
97///
98/// # Create a new buffer
99/// buffer = Buffer()
100///
101/// # Write some data - small writes are batched, large writes are zero-copy
102/// buffer.write(b"small")  # copied into pending buffer
103/// buffer.write(b"x" * 1000)  # stored as zero-copy reference
104/// ```
105#[pyclass(subclass, module = "monarch._rust_bindings.monarch_hyperactor.buffers")]
106#[derive(Clone)]
107pub struct Buffer {
108    /// Finalized fragments in write order
109    fragments: Vec<Fragment>,
110    /// Accumulator for pending small writes
111    pending: BytesMut,
112    /// Threshold below which writes are copied into a contiguous buffer.
113    /// Writes >= this size are stored as zero-copy references.
114    threshold: usize,
115}
116
117#[pymethods]
118impl Buffer {
119    /// Creates a new empty buffer.
120    ///
121    /// # Returns
122    /// A new empty `Buffer` instance.
123    #[new]
124    fn new() -> Self {
125        Self {
126            fragments: Vec::new(),
127            pending: BytesMut::new(),
128            threshold: hyperactor_config::global::get(SMALL_WRITE_THRESHOLD),
129        }
130    }
131
132    /// Writes bytes data to the buffer.
133    ///
134    /// Small writes (< 256 bytes) are copied into a contiguous buffer.
135    /// Large writes (>= 256 bytes) are stored as zero-copy references.
136    ///
137    /// # Arguments
138    /// * `buff` - The bytes object to write to the buffer
139    ///
140    /// # Returns
141    /// The number of bytes written (always equal to the length of input bytes)
142    fn write(&mut self, buff: &Bound<'_, PyBytes>) -> usize {
143        let bytes_written = buff.as_bytes().len();
144
145        if bytes_written < self.threshold {
146            self.pending.extend_from_slice(buff.as_bytes());
147        } else {
148            self.flush_pending();
149            self.fragments
150                .push(Fragment::Reference(buff.clone().unbind()));
151        }
152        bytes_written
153    }
154
155    /// Returns the total number of bytes in the buffer.
156    ///
157    /// This sums the lengths of all fragments (both copied and zero-copy) plus pending bytes.
158    ///
159    /// # Returns
160    /// The total number of bytes stored in the buffer
161    fn __len__(&self) -> usize {
162        let fragments_len: usize = monarch_with_gil_blocking(GilSite::Convert, |py| {
163            self.fragments
164                .iter()
165                .map(|frag| match frag {
166                    Fragment::Copy(bytes) => bytes.len(),
167                    Fragment::Reference(py_bytes) => py_bytes.as_bytes(py).len(),
168                })
169                .sum()
170        });
171        fragments_len + self.pending.len()
172    }
173
174    /// Freezes the buffer, converting it into an immutable `FrozenBuffer` for reading.
175    ///
176    /// This consumes all accumulated PyBytes and converts them into a contiguous bytes buffer.
177    /// After freezing, the original buffer is cleared.
178    ///
179    /// This operation should avoided in hot paths as it creates a copy in order to concatenate
180    /// bytes that are fragmented in memory into a single series of contiguous bytes
181    ///
182    /// # Returns
183    /// A new `FrozenBuffer` containing all the bytes that were written to this buffer
184    #[pyo3(name = "freeze")]
185    fn py_freeze(&mut self) -> FrozenBuffer {
186        Buffer::freeze(self)
187    }
188}
189
190impl Default for Buffer {
191    fn default() -> Self {
192        Self {
193            fragments: Vec::new(),
194            pending: BytesMut::new(),
195            threshold: hyperactor_config::global::get(SMALL_WRITE_THRESHOLD),
196        }
197    }
198}
199
200impl Buffer {
201    fn flush_pending(&mut self) {
202        if !self.pending.is_empty() {
203            let bytes = std::mem::take(&mut self.pending).freeze();
204            self.fragments.push(Fragment::Copy(bytes));
205        }
206    }
207
208    /// Converts accumulated data to [`Part`] for zero-copy multipart messages.
209    ///
210    /// Flushes any pending small writes and converts all fragments to bytes::Bytes.
211    pub fn take_part(&mut self) -> Part {
212        self.flush_pending();
213
214        let fragments = std::mem::take(&mut self.fragments);
215
216        Part::from_fragments(
217            fragments
218                .into_iter()
219                .map(|frag| match frag {
220                    Fragment::Copy(bytes) => bytes,
221                    Fragment::Reference(py_bytes) => {
222                        let wrapper = KeepPyBytesAlive::new(py_bytes);
223                        bytes::Bytes::from_owner(wrapper)
224                    }
225                })
226                .collect::<Vec<_>>(),
227        )
228    }
229
230    /// Freezes the buffer, converting it into an immutable `FrozenBuffer` for reading.
231    ///
232    /// This is the Rust-accessible version of the Python freeze method.
233    pub fn freeze(&mut self) -> FrozenBuffer {
234        let part = self.take_part();
235        FrozenBuffer {
236            inner: part.into_bytes(),
237        }
238    }
239}
240
241/// An immutable buffer for reading bytes data.
242///
243/// The `FrozenBuffer` struct provides a read-only interface to byte data. Once created,
244/// the buffer's content cannot be modified, but it supports various reading operations
245/// including line-by-line reading and copying data to external buffers. It implements
246/// Python's buffer protocol for zero-copy access from Python code.
247///
248/// # Examples
249///
250/// ```python
251/// from monarch._rust_bindings.monarch_hyperactor.buffers import Buffer
252///
253/// # Create and populate a buffer
254/// buffer = Buffer()
255/// buffer.write(b"Hello\nWorld\n")
256///
257/// # Freeze it for reading
258/// frozen = buffer.freeze()
259///
260/// # Read all content
261/// content = frozen.read()
262/// print(content)  # b"Hello\nWorld\n"
263///
264/// # Read line by line (create a new frozen buffer)
265/// buffer.write(b"Line 1\nLine 2\n")
266/// frozen = buffer.freeze()
267/// line1 = frozen.readline()
268/// line2 = frozen.readline()
269/// ```
270#[pyclass(subclass, module = "monarch._rust_bindings.monarch_hyperactor.buffers")]
271#[derive(Clone, Serialize, Deserialize, Named, PartialEq, Default)]
272pub struct FrozenBuffer {
273    pub inner: bytes::Bytes,
274}
275wirevalue::register_type!(FrozenBuffer);
276
277impl From<Vec<u8>> for FrozenBuffer {
278    fn from(v: Vec<u8>) -> Self {
279        Self {
280            inner: bytes::Bytes::from(v),
281        }
282    }
283}
284
285impl From<&'static [u8]> for FrozenBuffer {
286    fn from(v: &'static [u8]) -> Self {
287        Self {
288            inner: bytes::Bytes::from_static(v),
289        }
290    }
291}
292
293impl From<FrozenBuffer> for Part {
294    fn from(buf: FrozenBuffer) -> Self {
295        Part::from(buf.inner)
296    }
297}
298
299#[pymethods]
300impl FrozenBuffer {
301    /// Reads bytes from the buffer.
302    ///
303    /// Advances the internal read position by the number of bytes read.
304    /// This is a consuming operation - once bytes are read, they cannot be read again.
305    ///
306    /// # Arguments
307    /// * `size` - Number of bytes to read. If -1 or not provided, reads all remaining bytes
308    ///
309    /// # Returns
310    /// A PyBytes object containing the bytes read from the buffer
311    #[pyo3(signature=(size=-1))]
312    fn read(mut slf: PyRefMut<'_, Self>, size: i64) -> Bound<'_, PyBytes> {
313        let size = if size <= 0 {
314            slf.inner.remaining() as i64
315        } else {
316            size.min(slf.inner.remaining() as i64)
317        } as usize;
318        let out = PyBytes::new(slf.py(), &slf.inner[..size]);
319        slf.inner.advance(size);
320        out
321    }
322
323    /// Returns the number of bytes remaining in the buffer.
324    ///
325    /// # Returns
326    /// The number of bytes that can still be read from the buffer
327    fn __len__(&self) -> usize {
328        self.inner.remaining()
329    }
330
331    /// Returns a string representation of the buffer content.
332    ///
333    /// This method provides a debug representation of the remaining bytes in the buffer.
334    ///
335    /// # Returns
336    /// A string showing the bytes remaining in the buffer
337    fn __str__(&self) -> String {
338        format!("{:?}", &self.inner[..])
339    }
340
341    /// Implements Python's buffer protocol for zero-copy access.
342    ///
343    /// This method allows Python code to access the buffer's underlying data without copying,
344    /// enabling efficient integration with memoryview, numpy arrays, and other buffer-aware
345    /// Python objects. The buffer is read-only and cannot be modified through this interface.
346    ///
347    /// # Safety
348    /// This method uses unsafe FFI calls to implement Python's buffer protocol.
349    /// The implementation ensures that:
350    /// - The buffer is marked as read-only
351    /// - A reference to the PyObject is held to prevent garbage collection
352    /// - Proper buffer metadata is set for Python interoperability
353    ///
354    /// Adapted from https://docs.rs/crate/pyo3/latest/source/tests/test_buffer.rs
355    unsafe fn __getbuffer__(
356        slf: PyRefMut<'_, Self>,
357        view: *mut pyo3::ffi::Py_buffer,
358        flags: c_int,
359    ) -> PyResult<()> {
360        if view.is_null() {
361            panic!("view is null");
362        }
363        if (flags & pyo3::ffi::PyBUF_WRITABLE) == pyo3::ffi::PyBUF_WRITABLE {
364            panic!("object not writable");
365        }
366        let bytes = &slf.inner;
367        // SAFETY: The view pointer is valid and we're setting up the buffer metadata correctly.
368        // The PyObject reference is held by setting (*view).obj to prevent garbage collection.
369        unsafe {
370            (*view).buf = bytes.as_ptr() as *mut c_void;
371            (*view).len = bytes.len() as isize;
372            (*view).readonly = 1;
373            (*view).itemsize = 1;
374            (*view).ndim = 1;
375            (*view).shape = &mut (*view).len;
376            (*view).strides = &mut (*view).itemsize;
377            (*view).suboffsets = std::ptr::null_mut();
378            (*view).internal = std::ptr::null_mut();
379            // This holds on to the reference to prevent garbage collection
380            (*view).obj = slf.into_ptr();
381        }
382        Ok(())
383    }
384
385    /// Reads a line from the buffer up to a newline character.
386    ///
387    /// Searches for the first newline character ('\n') within the specified size limit
388    /// and returns all bytes up to and including that character. If no newline is found
389    /// within the limit, returns up to `size` bytes. Advances the read position by the
390    /// number of bytes read.
391    ///
392    /// # Arguments
393    /// * `size` - Maximum number of bytes to read. If -1 or not provided, searches through all remaining bytes
394    ///
395    /// # Returns
396    /// A PyBytes object containing the line data (including the newline character if found)
397    #[pyo3(signature=(size=-1))]
398    fn readline<'py>(&mut self, py: Python<'py>, size: i64) -> Bound<'py, PyBytes> {
399        let max_size = if size < 0 {
400            self.inner.remaining() as i64
401        } else {
402            size.min(self.inner.remaining() as i64)
403        } as usize;
404        let size = self.inner[..max_size]
405            .iter()
406            .position(|x| *x == b'\n')
407            .unwrap_or(max_size);
408
409        let tmp = PyBytes::new(py, &self.inner[..max_size]);
410        self.inner.advance(size);
411        tmp
412    }
413
414    /// Reads bytes from the buffer into an existing buffer-like object.
415    ///
416    /// This method implements efficient copying of data from the FrozenBuffer into
417    /// any Python object that supports the buffer protocol (like bytearray, memoryview, etc.).
418    /// The number of bytes copied is limited by either the remaining bytes in this buffer
419    /// or the capacity of the destination buffer, whichever is smaller.
420    ///
421    /// # Arguments
422    /// * `b` - Any Python object that supports the buffer protocol for writing
423    ///
424    /// # Returns
425    /// The number of bytes actually copied into the destination buffer
426    ///
427    /// # Errors
428    /// Returns a PyBufferError if the destination object doesn't support the buffer protocol
429    /// or if there's an error during the copy operation
430    fn readinto<'py>(&mut self, py: Python<'py>, b: &Bound<'py, PyAny>) -> PyResult<i64> {
431        let buff: PyBuffer<u8> = PyBuffer::get(b)?;
432        let to_write = self.inner.remaining().min(buff.item_count());
433        buff.copy_from_slice(py, &self.inner[..to_write])?;
434        self.inner.advance(to_write);
435        Ok(to_write as i64)
436    }
437}
438
439pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
440    hyperactor_mod.add_class::<Buffer>()?;
441    hyperactor_mod.add_class::<FrozenBuffer>()?;
442    Ok(())
443}