Skip to main content

monarch_hyperactor/
pickle.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//! Pickling support for Monarch.
10//!
11//! This module pickles Python objects for actor messages, collecting tensor
12//! engine references and out-of-band mesh references during serialization so
13//! they are restored together on decode.
14//!
15//! ## Out-of-band mesh-reference invariants
16//!
17//! Mesh references (proc/host/actor meshes) are not pickled inline. They ride
18//! *out of band* in a `refs` table carried next to the payload bytes, leaving a
19//! `pop_mesh_reference` sentinel in the pickle stream. Two invariants keep the
20//! table and its payload from ever drifting apart:
21//!
22//! **REFS-1 (ref-aware decode).** Any payload that can carry out-of-band refs
23//! must be decoded only through a ref-aware path: `PicklingState::from_parts`
24//! and `PicklingState::unpickle`, as wrapped by `PythonMessage::decode` and
25//! `PythonResponseMessage::decode`. A bare `pickle.loads`/`cloudpickle.loads`
26//! on the payload bytes drops the table, so a sentinel later pops with no active
27//! state and raises "No active pickling state". The raw payload bytes are
28//! deliberately not exposed on `PythonMessage` (there is no `.message` getter),
29//! so a bare decode is not even expressible from Python.
30//!
31//! **REFS-2 (refs preserved through intermediates).** Any intermediate that
32//! relays a payload -- `PythonResponseMessage` and the `ValueOverlay` behind a
33//! `.call()` valuemesh -- must carry its `refs` beside the bytes, all the way to
34//! the decode site. Dropping refs at a relay boundary reintroduces the REFS-1
35//! failure downstream. Refs are therefore part of `PythonResponseMessage`
36//! equality: two runs with identical bytes but different refs must not coalesce.
37//!
38//! These invariants have one sound exception. A table entry and a
39//! `pop_mesh_reference` sentinel are emitted together during pickling (1:1),
40//! so an empty `refs` table means the payload carries no sentinels: a bare
41//! decode of it pops nothing and is sound. The `.call()` valuemesh collector
42//! (`collect_valuemesh` in `endpoint.rs`) relies on this to preserve
43//! D96180139's lazy unpickle: it decodes a ref-empty batch lazily via
44//! `PyValueMesh::build_from_parts` (`value_mesh.rs`), which resolves on access
45//! outside the ref-aware path, and only a ref-carrying batch eagerly via
46//! `build_from_objects`. That lazy build is the sole bare decode of a payload
47//! that *could* carry refs; see `collect_valuemesh` for the gate itself.
48
49use std::cell::RefCell;
50use std::collections::VecDeque;
51use std::sync::atomic::AtomicUsize;
52use std::sync::atomic::Ordering;
53
54use monarch_types::py_global;
55use pyo3::IntoPyObjectExt;
56use pyo3::prelude::*;
57use pyo3::types::PyList;
58use pyo3::types::PyTuple;
59use serde_multipart::Part;
60
61use crate::actor::MeshRef;
62use crate::actor::PyMeshRef;
63use crate::actor::PythonMessage;
64use crate::actor::PythonMessageKind;
65use crate::buffers::Buffer;
66use crate::pytokio::PyPythonTask;
67use crate::pytokio::PyShared;
68use crate::runtime::GilSite;
69use crate::runtime::monarch_with_gil_blocking;
70
71// cloudpickle module for serialization
72py_global!(cloudpickle, "cloudpickle", "cloudpickle");
73
74py_global!(_unpickle, "pickle", "loads");
75
76// Importing monarch._src.actor.pickle applies a monkeypatch to cloudpickle
77// that injects RemoteImportLoader into pickled function globals, enabling
78// source loading for pickle-by-value code on remote hosts (needed for
79// debugger and tracebacks). We access this before pickling to ensure
80// the monkeypatch is applied.
81py_global!(
82    pickle_monkeypatch,
83    "monarch._src.actor.pickle",
84    "_function_getstate"
85);
86
87// Check if torch has been loaded into the current Python process.
88// Returns the torch module if loaded, otherwise None.
89py_global!(maybe_torch_fn, "monarch._src.actor.pickle", "maybe_torch");
90
91// Torch-aware dump function: uses a Pickler subclass with dispatch_table
92// entries for torch storage types (UntypedStorage, TypedStorage, etc.).
93py_global!(torch_dump_fn, "monarch._src.actor.pickle", "torch_dump");
94
95// Torch-aware loads function: wraps cloudpickle.loads with
96// torch.utils._python_dispatch._disable_current_modes().
97py_global!(torch_loads_fn, "monarch._src.actor.pickle", "torch_loads");
98
99// Shared class for pickling PyShared values
100py_global!(
101    shared_class,
102    "monarch._rust_bindings.monarch_hyperactor.pytokio",
103    "Shared"
104);
105
106// Thread-local storage for the active pickling state.
107// Set by pickle/unpickle operations so free functions used in __reduce__
108// implementations can access it.
109//
110// It is thread-local by design: two threads pickling at once (even the same
111// mesh ref) collect into independent `mesh_references` / `pending_mesh_fills`,
112// so there is nothing shared to race on. `pickle()` then moves the state out of
113// the thread-local into an owned `PicklingState` before the GIL-releasing
114// `PicklingState::resolve`, so the sender-side slot fill mutates a single-owner
115// value across its awaits, never this thread-local. Both properties (thread-
116// local, and moved out before resolve) are what make releasing the GIL safe.
117thread_local! {
118    static ACTIVE_PICKLING_STATE: RefCell<Option<ActivePicklingState>> = const { RefCell::new(None) };
119}
120
121/// Counters for tests. `PENDING_RESERVE_COUNT` bumps when a still-pending mesh
122/// reserves an out-of-band slot on the send side; it is pending-specific (a
123/// resolved mesh fills directly), so it proves the pending path ran.
124/// `MESH_POP_COUNT` bumps when any out-of-band mesh reference is reunited on the
125/// decode side; it is not pending-specific, since a resolved mesh also travels
126/// out-of-band, so it proves receive and reconstruct, not pending-ness.
127static PENDING_RESERVE_COUNT: AtomicUsize = AtomicUsize::new(0);
128static MESH_POP_COUNT: AtomicUsize = AtomicUsize::new(0);
129
130/// RAII guard that sets the thread-local `ACTIVE_PICKLING_STATE` on creation
131/// and restores the previous state (if any) on drop. This supports nesting:
132/// if a guard already exists, the new guard saves the old state and restores
133/// it when dropped, even on panic.
134struct ActivePicklingGuard {
135    previous: Option<ActivePicklingState>,
136}
137
138impl ActivePicklingGuard {
139    /// Set `state` as the active pickling state, saving any existing state.
140    fn enter(state: ActivePicklingState) -> Self {
141        let previous = ACTIVE_PICKLING_STATE.with(|cell| cell.borrow_mut().replace(state));
142        Self { previous }
143    }
144}
145
146impl Drop for ActivePicklingGuard {
147    fn drop(&mut self) {
148        ACTIVE_PICKLING_STATE.with(|cell| {
149            *cell.borrow_mut() = self.previous.take();
150        });
151    }
152}
153
154/// State maintained during active pickling/unpickling operations.
155///
156/// This is the thread-local state used while cloudpickle is running.
157/// It collects tensor engine references and pending pickles during serialization.
158struct ActivePicklingState {
159    /// References to tensor engine objects that need special handling.
160    tensor_engine_references: VecDeque<Py<PyAny>>,
161    /// Mesh references collected out-of-band into the message's `refs` table.
162    /// A `None` is a slot reserved for a pending mesh, filled sender-side.
163    mesh_references: VecDeque<Option<MeshRef>>,
164    /// Pending mesh slots awaiting a sender-side fill: (index into
165    /// `mesh_references`, the handle whose resolved mesh supplies the ref).
166    pending_mesh_fills: Vec<(usize, Py<PyShared>)>,
167    /// Whether tensor engine references are allowed in this pickling context.
168    allow_tensor_engine_references: bool,
169    /// Whether mesh references are collected out-of-band in this context.
170    allow_mesh_references: bool,
171}
172
173impl ActivePicklingState {
174    /// Create a new ActivePicklingState.
175    fn new(allow_tensor_engine_references: bool, allow_mesh_references: bool) -> Self {
176        Self {
177            tensor_engine_references: VecDeque::new(),
178            mesh_references: VecDeque::new(),
179            pending_mesh_fills: Vec::new(),
180            allow_tensor_engine_references,
181            allow_mesh_references,
182        }
183    }
184
185    /// Convert this active state into a frozen PicklingState.
186    fn into_pickling_state(self, buffer: Part) -> PicklingStateInner {
187        PicklingStateInner {
188            buffer,
189            tensor_engine_references: self.tensor_engine_references,
190            mesh_references: self.mesh_references,
191            pending_mesh_fills: self.pending_mesh_fills,
192        }
193    }
194}
195
196/// Inner data for a completed pickling operation.
197///
198/// This contains the pickled bytes as a fragmented [`Part`] (zero-copy)
199/// and any collected references.
200pub struct PicklingStateInner {
201    /// The pickled bytes as a fragmented Part (zero-copy).
202    buffer: Part,
203    /// References to tensor engine objects that need special handling.
204    tensor_engine_references: VecDeque<Py<PyAny>>,
205    /// Mesh references carried out-of-band into the message's `refs` table.
206    /// A `None` is a slot reserved for a pending mesh, filled sender-side.
207    mesh_references: VecDeque<Option<MeshRef>>,
208    /// Pending mesh slots awaiting a sender-side fill.
209    pending_mesh_fills: Vec<(usize, Py<PyShared>)>,
210}
211
212impl PicklingStateInner {
213    /// Take the Part (pickled bytes) from this inner state.
214    pub fn take_buffer(self) -> Part {
215        self.buffer
216    }
217}
218
219/// Python-visible wrapper for the result of a pickling operation.
220///
221/// Contains the pickled bytes and any tensor engine references or pending
222/// pickles that were collected during serialization.
223#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.pickle")]
224pub struct PicklingState {
225    inner: Option<PicklingStateInner>,
226}
227
228impl PicklingState {
229    pub fn take_inner(&mut self) -> PyResult<PicklingStateInner> {
230        self.inner.take().ok_or_else(|| {
231            pyo3::exceptions::PyRuntimeError::new_err("PicklingState has already been consumed")
232        })
233    }
234
235    fn inner_ref(&self) -> PyResult<&PicklingStateInner> {
236        self.inner.as_ref().ok_or_else(|| {
237            pyo3::exceptions::PyRuntimeError::new_err("PicklingState has already been consumed")
238        })
239    }
240
241    /// Build a PicklingState directly from already-separated parts: the pickled
242    /// `buffer`, the ordered local-state/tensor-engine list, and the resolved
243    /// mesh-reference table. Used by `PythonMessage::decode` so a message's
244    /// payload is always unpickled together with its `refs`.
245    pub(crate) fn from_parts(
246        buffer: Part,
247        tensor_engine_references: VecDeque<Py<PyAny>>,
248        mesh_references: VecDeque<Option<MeshRef>>,
249    ) -> Self {
250        Self {
251            inner: Some(PicklingStateInner {
252                buffer,
253                tensor_engine_references,
254                mesh_references,
255                pending_mesh_fills: Vec::new(),
256            }),
257        }
258    }
259}
260
261#[pymethods]
262impl PicklingState {
263    /// Create a new PicklingState from a buffer and optional tensor engine references.
264    ///
265    /// This is used for unpickling received messages that may contain tensor engine
266    /// references that need to be restored during deserialization.
267    #[new]
268    #[pyo3(signature = (buffer, tensor_engine_references=None, mesh_references=None))]
269    fn py_new(
270        py: Python<'_>,
271        buffer: PyRef<'_, crate::buffers::FrozenBuffer>,
272        tensor_engine_references: Option<&Bound<'_, PyList>>,
273        mesh_references: Option<Vec<Py<PyMeshRef>>>,
274    ) -> PyResult<Self> {
275        let refs: VecDeque<Py<PyAny>> = tensor_engine_references
276            .map(|list| list.iter().map(|item| item.unbind()).collect())
277            .unwrap_or_default();
278
279        // pyo3 type-checks each element as a `PyMeshRef` during extraction.
280        let mesh_refs: VecDeque<Option<MeshRef>> = mesh_references
281            .map(|list| {
282                list.into_iter()
283                    .map(|m| Some(m.borrow(py).inner.clone()))
284                    .collect()
285            })
286            .unwrap_or_default();
287
288        Ok(Self {
289            inner: Some(PicklingStateInner {
290                buffer: Part::from(buffer.inner.clone()),
291                tensor_engine_references: refs,
292                mesh_references: mesh_refs,
293                pending_mesh_fills: Vec::new(),
294            }),
295        })
296    }
297
298    /// Get a copy of all tensor engine references from this pickling state.
299    ///
300    /// Returns a Python list containing copies of the tensor engine references.
301    fn tensor_engine_references(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
302        let inner = self.inner_ref()?;
303        let refs: Vec<Py<PyAny>> = inner
304            .tensor_engine_references
305            .iter()
306            .map(|r| r.clone_ref(py))
307            .collect();
308        Ok(PyList::new(py, refs)?.unbind())
309    }
310
311    /// Get the buffer from this pickling state.
312    ///
313    /// Returns a FrozenBuffer containing the pickled bytes.
314    /// This does not consume the PicklingState.
315    fn buffer(&self) -> PyResult<crate::buffers::FrozenBuffer> {
316        let inner = self.inner_ref()?;
317        Ok(crate::buffers::FrozenBuffer {
318            inner: inner.buffer.clone().into_bytes(),
319        })
320    }
321
322    /// Unpickle the buffer contents.
323    ///
324    /// This consumes the PicklingState. It will fail if there are any pending
325    /// pickles that haven't been resolved.
326    pub(crate) fn unpickle(&mut self, py: Python<'_>) -> PyResult<Py<PyAny>> {
327        let inner = self.take_inner()?;
328
329        // Set up an active state for unpickling (to handle pop calls).
330        // The guard restores any previous state on drop (including on panic).
331        let mut active = ActivePicklingState::new(false, false);
332        active.tensor_engine_references = inner.tensor_engine_references;
333        active.mesh_references = inner.mesh_references;
334        active.pending_mesh_fills = inner.pending_mesh_fills;
335
336        let _guard = ActivePicklingGuard::enter(active);
337
338        let frozen = crate::buffers::FrozenBuffer {
339            inner: inner.buffer.into_bytes(),
340        };
341
342        // Unpickle the object. If torch is loaded, use torch_loads which
343        // disables dispatch modes during unpickling.
344        let result = if maybe_torch_fn(py).call0()?.is_truthy()? {
345            torch_loads_fn(py).call1((frozen,))
346        } else {
347            cloudpickle(py).getattr("loads")?.call1((frozen,))
348        };
349
350        result.map(|obj| obj.unbind())
351    }
352}
353
354impl PicklingState {
355    /// Fill the reserved mesh slots from their pending handles, producing a
356    /// PicklingState whose out-of-band `refs` table is fully populated.
357    ///
358    /// Awaits each pending mesh's init task, extracts its `*MeshRef`, and writes
359    /// it into the reserved slot. The payload bytes are unchanged (no re-pickle).
360    pub async fn resolve(mut self) -> PyResult<PicklingState> {
361        // Take the pending mesh fills out: a plain move, no GIL and no
362        // `clone_ref`. Each is a slot index plus the handle whose resolved mesh
363        // supplies the ref.
364        let fills = std::mem::take(
365            &mut self
366                .inner
367                .as_mut()
368                .ok_or_else(|| {
369                    pyo3::exceptions::PyRuntimeError::new_err(
370                        "PicklingState has already been consumed",
371                    )
372                })?
373                .pending_mesh_fills,
374        );
375
376        for (index, handle) in fills {
377            // Await the mesh's init task (these run concurrently, so the wait
378            // is the slowest init, not the sum).
379            let mut task =
380                monarch_with_gil_blocking(GilSite::AwaitDrive, |py| handle.borrow(py).task())?;
381            task.take_task()?.await?;
382
383            // Extract the `*MeshRef` from the now-resolved mesh and fill the slot.
384            monarch_with_gil_blocking(GilSite::Convert, |py| -> PyResult<()> {
385                let value = handle.borrow(py).poll()?.ok_or_else(|| {
386                    pyo3::exceptions::PyRuntimeError::new_err("pending mesh handle did not resolve")
387                })?;
388                let mesh_ref = crate::actor::mesh_ref_from_pyobject(value.bind(py))?;
389                if let Some(inner) = self.inner.as_mut() {
390                    inner.mesh_references[index] = Some(mesh_ref);
391                }
392                Ok(())
393            })?;
394        }
395
396        Ok(self)
397    }
398}
399
400/// A message whose reserved mesh slots must be filled before it can be sent.
401///
402/// Contains a `PythonMessageKind` and a `PicklingState`. The `PicklingState` may
403/// hold mesh slots reserved for pending meshes, filled sender-side once their
404/// handles resolve, before the message can be converted into a `PythonMessage`.
405#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.pickle")]
406pub struct PendingMessage {
407    pub(crate) kind: PythonMessageKind,
408    state: PicklingState,
409}
410
411impl PendingMessage {
412    /// Create a new PendingMessage from a kind and pickling state.
413    pub fn new(kind: PythonMessageKind, state: PicklingState) -> Self {
414        Self { kind, state }
415    }
416
417    /// Take ownership of the inner state from a mutable reference.
418    ///
419    /// This is used by pyo3 pymethods that receive `&mut PendingMessage`
420    /// but need to pass ownership to the trait method.
421    pub fn take(&mut self) -> PyResult<PendingMessage> {
422        let inner = self.state.take_inner()?;
423        Ok(PendingMessage {
424            kind: std::mem::take(&mut self.kind),
425            state: PicklingState { inner: Some(inner) },
426        })
427    }
428
429    /// Resolve any reserved mesh slots and convert this into a PythonMessage.
430    ///
431    /// This is an async method that:
432    /// 1. Fills the reserved mesh slots from their pending handles
433    /// 2. Builds a PythonMessage with the resolved bytes and `refs` table
434    pub async fn resolve(self) -> PyResult<PythonMessage> {
435        // Fill any reserved mesh slots, then build the message with the
436        // finalized `refs` table. No GIL needed for the assembly: neither the
437        // Part nor the MeshRef table holds Py<> values.
438        let mut resolved_state = self.state.resolve().await?;
439
440        let inner = resolved_state.take_inner()?;
441        let refs: Vec<MeshRef> = inner
442            .mesh_references
443            .into_iter()
444            .map(|r| {
445                r.ok_or_else(|| {
446                    pyo3::exceptions::PyRuntimeError::new_err(
447                        "mesh reference slot was never filled",
448                    )
449                })
450            })
451            .collect::<PyResult<_>>()?;
452        Ok(PythonMessage::new_from_buf_with_refs(
453            self.kind,
454            inner.buffer,
455            refs,
456        ))
457    }
458}
459
460#[pymethods]
461impl PendingMessage {
462    /// Create a new PendingMessage from a kind and pickling state.
463    #[new]
464    pub fn py_new(
465        kind: PythonMessageKind,
466        mut state: PyRefMut<'_, PicklingState>,
467    ) -> PyResult<Self> {
468        // Take the inner state from the PicklingState
469        let inner = state.take_inner()?;
470        Ok(Self {
471            kind,
472            state: PicklingState { inner: Some(inner) },
473        })
474    }
475
476    /// Get the message kind.
477    #[getter]
478    fn kind(&self) -> PythonMessageKind {
479        self.kind.clone()
480    }
481
482    /// Fill reserved mesh slots and return a fully materialized PythonMessage.
483    #[pyo3(name = "resolve")]
484    fn py_resolve(&mut self) -> PyResult<PyPythonTask> {
485        let message = self.take()?;
486        PyPythonTask::new(async move { message.resolve().await })
487    }
488}
489
490/// Push a tensor engine reference to the active pickling state if one is active.
491///
492/// This is called from Python during pickling when a tensor engine object
493/// is encountered that needs special handling.
494///
495/// Returns False if there is no active pickling state.
496/// Returns True if the reference was successfully pushed.
497/// Raises an error if tensor engine references are not allowed in the current pickling context.
498#[pyfunction]
499fn push_tensor_engine_reference_if_active(obj: Py<PyAny>) -> PyResult<bool> {
500    ACTIVE_PICKLING_STATE.with(|cell| {
501        let mut state = cell.borrow_mut();
502        match state.as_mut() {
503            Some(s) => {
504                if !s.allow_tensor_engine_references {
505                    return Err(pyo3::exceptions::PyRuntimeError::new_err(
506                        "Tensor engine references are not allowed in the current pickling context",
507                    ));
508                }
509                s.tensor_engine_references.push_back(obj);
510                Ok(true)
511            }
512            None => Ok(false),
513        }
514    })
515}
516
517/// Pop a tensor engine reference from the active pickling state.
518///
519/// This is called from Python during unpickling to retrieve tensor engine
520/// objects in the order they were pushed.
521#[pyfunction]
522fn pop_tensor_engine_reference(py: Python<'_>) -> PyResult<Py<PyAny>> {
523    ACTIVE_PICKLING_STATE
524        .with(|cell| {
525            let mut state = cell.borrow_mut();
526            match state.as_mut() {
527                Some(s) => s.tensor_engine_references.pop_front().ok_or_else(|| {
528                    pyo3::exceptions::PyRuntimeError::new_err(
529                        "No tensor engine references remaining",
530                    )
531                }),
532                None => Err(pyo3::exceptions::PyRuntimeError::new_err(
533                    "No active pickling state",
534                )),
535            }
536        })
537        .map(|obj| obj.clone_ref(py))
538}
539
540/// Pop a mesh reference from the active pickling state and rebuild its
541/// Python mesh wrapper.
542///
543/// Called from the unpickle stream wherever a mesh slot was emitted in
544/// place of inline bytes.
545#[pyfunction]
546fn pop_mesh_reference(py: Python<'_>) -> PyResult<Py<PyAny>> {
547    let mesh_ref = ACTIVE_PICKLING_STATE.with(|cell| {
548        let mut state = cell.borrow_mut();
549        match state.as_mut() {
550            Some(s) => s.mesh_references.pop_front().ok_or_else(|| {
551                pyo3::exceptions::PyRuntimeError::new_err("No mesh references remaining")
552            }),
553            None => Err(pyo3::exceptions::PyRuntimeError::new_err(
554                "No active pickling state",
555            )),
556        }
557    })?;
558    let mesh_ref = mesh_ref.ok_or_else(|| {
559        pyo3::exceptions::PyRuntimeError::new_err("mesh reference slot was never filled")
560    })?;
561    MESH_POP_COUNT.fetch_add(1, Ordering::Relaxed);
562    mesh_ref.reconstruct(py)
563}
564
565/// Push a mesh reference to the active pickling state if mesh-reference
566/// collection is active in this context.
567///
568/// Returns true if the reference was collected (the caller emits a slot);
569/// false otherwise (the caller inlines the reference as usual).
570pub fn push_mesh_reference_if_active(mesh_ref: MeshRef) -> bool {
571    ACTIVE_PICKLING_STATE.with(|cell| {
572        let mut state = cell.borrow_mut();
573        match state.as_mut() {
574            Some(s) if s.allow_mesh_references => {
575                s.mesh_references.push_back(Some(mesh_ref));
576                true
577            }
578            _ => false,
579        }
580    })
581}
582
583/// Reserve a slot for a *pending* mesh whose `*MeshRef` is not available yet,
584/// registering `handle` for a sender-side fill once the mesh resolves.
585///
586/// Returns true if a slot was reserved (the caller emits a `pop_mesh_reference`
587/// placeholder); false otherwise (the caller keeps its current behavior).
588pub fn reserve_mesh_reference_if_active(handle: Py<PyShared>) -> bool {
589    ACTIVE_PICKLING_STATE.with(|cell| {
590        let mut state = cell.borrow_mut();
591        match state.as_mut() {
592            Some(s) if s.allow_mesh_references => {
593                let index = s.mesh_references.len();
594                s.mesh_references.push_back(None);
595                s.pending_mesh_fills.push((index, handle));
596                PENDING_RESERVE_COUNT.fetch_add(1, Ordering::Relaxed);
597                true
598            }
599            _ => false,
600        }
601    })
602}
603
604/// Python-callable wrapper over [`reserve_mesh_reference_if_active`] for the
605/// typed Proc/Host reduces, which reserve their pending slot from Python.
606#[pyfunction]
607fn reserve_mesh_reference(handle: Py<PyShared>) -> bool {
608    reserve_mesh_reference_if_active(handle)
609}
610
611/// Reduce a PyShared for pickling.
612///
613/// This function implements the pickle protocol for PyShared:
614/// 1. If the shared is already finished, return (Shared.from_value, (value,))
615/// 2. Otherwise, block on the shared and return (Shared.from_value, (value,))
616pub fn reduce_shared<'py>(
617    py: Python<'py>,
618    py_shared: &Bound<'py, PyShared>,
619) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyTuple>)> {
620    // First, check if the shared is already finished
621    if let Some(value) = py_shared.borrow().poll()? {
622        let from_value = shared_class(py).getattr("from_value")?;
623        let args = PyTuple::new(py, [value])?;
624        return Ok((from_value, args));
625    }
626
627    // Pending: block on the shared. Meshes ride the out-of-band table via their
628    // own type-specific reducers; this generic path is the fallback for any
629    // other pending `PyShared`.
630    let value = PyShared::block_on(py_shared.borrow(), py)?;
631    let from_value = shared_class(py).getattr("from_value")?;
632    let args = PyTuple::new(py, [value])?;
633    Ok((from_value, args))
634}
635
636/// Pickle a Python object into a [`Buffer`].
637///
638/// This is the shared pickling core. The caller is responsible for setting up
639/// the [`ActivePicklingGuard`] before calling this function.
640fn pickle_into_buffer(py: Python<'_>, obj: &Py<PyAny>, buffer: &Py<Buffer>) -> PyResult<()> {
641    // Ensure the cloudpickle monkeypatch for RemoteImportLoader is applied.
642    pickle_monkeypatch(py);
643
644    // If torch is loaded, use the torch-aware pickler that handles
645    // torch storage types via dispatch_table.
646    if maybe_torch_fn(py).call0()?.is_truthy()? {
647        torch_dump_fn(py).call1((obj, buffer.bind(py)))?;
648    } else {
649        let pickler = cloudpickle(py)
650            .getattr("Pickler")?
651            .call1((buffer.bind(py),))?;
652        pickler.call_method1("dump", (obj,))?;
653    }
654
655    Ok(())
656}
657
658/// Pickle a Python object and return the serialized data as a [`Part`].
659///
660/// This is a simplified variant of [`pickle`] that disallows pending pickles
661/// and tensor engine references, and returns the raw serialized bytes instead
662/// of a [`PicklingState`].
663pub fn pickle_to_part(py: Python<'_>, obj: &Py<PyAny>) -> PyResult<Part> {
664    let active = ActivePicklingState::new(false, false);
665    let buffer = Py::new(py, Buffer::default())?;
666    let _guard = ActivePicklingGuard::enter(active);
667
668    pickle_into_buffer(py, obj, &buffer)?;
669
670    Ok(buffer.borrow_mut(py).take_part())
671}
672
673/// Pickle an object with support for pending pickles and tensor engine references.
674///
675/// This function creates a PicklingState and calls cloudpickle.dumps with
676/// an active thread-local PicklingState, allowing __reduce__ implementations
677/// to push tensor engine references and pending pickles.
678///
679/// # Arguments
680/// * `obj` - The Python object to pickle
681/// * `allow_tensor_engine_references` - If true, allow tensor engine references to be registered
682///
683/// # Returns
684/// A PicklingState containing the pickled buffer and any registered references/pending pickles
685#[pyfunction]
686#[pyo3(signature = (obj, allow_tensor_engine_references=true, allow_mesh_references=false))]
687pub fn pickle(
688    py: Python<'_>,
689    obj: Py<PyAny>,
690    allow_tensor_engine_references: bool,
691    allow_mesh_references: bool,
692) -> PyResult<PicklingState> {
693    let active = ActivePicklingState::new(allow_tensor_engine_references, allow_mesh_references);
694    let buffer = Py::new(py, Buffer::default())?;
695    let _guard = ActivePicklingGuard::enter(active);
696
697    pickle_into_buffer(py, &obj, &buffer)?;
698
699    // Take the state (which may have been modified during pickling).
700    // The guard will restore the previous state on drop.
701    let active = ACTIVE_PICKLING_STATE
702        .with(|cell| cell.borrow_mut().take())
703        .expect("active pickling state should still be set");
704
705    // Take the Part (zero-copy fragmented buffer) directly.
706    let part = buffer.borrow_mut(py).take_part();
707    let inner = active.into_pickling_state(part);
708    Ok(PicklingState { inner: Some(inner) })
709}
710
711pub(crate) fn unpickle(
712    py: Python<'_>,
713    buffer: crate::buffers::FrozenBuffer,
714) -> PyResult<Bound<'_, PyAny>> {
715    _unpickle(py).call1((buffer.into_py_any(py)?,))
716}
717
718/// Test helper: read the pending-mesh reserve counter.
719#[pyfunction]
720fn _get_pending_reserve_count() -> usize {
721    PENDING_RESERVE_COUNT.load(Ordering::Relaxed)
722}
723
724/// Test helper: reset the pending-mesh reserve counter to zero.
725#[pyfunction]
726fn _reset_pending_reserve_count() {
727    PENDING_RESERVE_COUNT.store(0, Ordering::Relaxed);
728}
729
730/// Test helper: read the mesh-pop counter.
731#[pyfunction]
732fn _get_mesh_pop_count() -> usize {
733    MESH_POP_COUNT.load(Ordering::Relaxed)
734}
735
736/// Test helper: reset the mesh-pop counter to zero.
737#[pyfunction]
738fn _reset_mesh_pop_count() {
739    MESH_POP_COUNT.store(0, Ordering::Relaxed);
740}
741
742/// Register the pickle Python bindings into the given module.
743pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
744    module.add_class::<PicklingState>()?;
745    module.add_class::<PendingMessage>()?;
746    module.add_function(wrap_pyfunction!(pickle, module)?)?;
747    module.add_function(wrap_pyfunction!(
748        push_tensor_engine_reference_if_active,
749        module
750    )?)?;
751    module.add_function(wrap_pyfunction!(pop_tensor_engine_reference, module)?)?;
752    module.add_function(wrap_pyfunction!(pop_mesh_reference, module)?)?;
753    module.add_function(wrap_pyfunction!(reserve_mesh_reference, module)?)?;
754    module.add_function(wrap_pyfunction!(_get_pending_reserve_count, module)?)?;
755    module.add_function(wrap_pyfunction!(_reset_pending_reserve_count, module)?)?;
756    module.add_function(wrap_pyfunction!(_get_mesh_pop_count, module)?)?;
757    module.add_function(wrap_pyfunction!(_reset_mesh_pop_count, module)?)?;
758    Ok(())
759}