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}