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