How Monty's Serialization Works for Execution State
Monty serializes execution state by moving ownership of the VM's operand stack, call frames, and heap into a VMSnapshot struct, wrapping it with the executor and namespaces in a serde-enabled Snapshot<T>, and encoding the result via postcard into a compact binary blob that can be restored later.
Monty, the sandboxed Python interpreter from pydantic/monty, enables programs to pause at external function calls or async await points and resume later—even across different processes—through sophisticated execution state serialization. This system captures the entire virtual machine state without expensive cloning operations, allowing deterministic pause and resume semantics.
Core Components: VMSnapshot and Snapshot Types
Monty's serialization architecture separates the raw VM state from the higher-level container that includes the heap and compiled bytecode. This separation enables zero-copy capture of the execution stack while still providing a fully serializable package.
VMSnapshot: The Raw VM State
The VMSnapshot struct holds the transient execution data that changes as Python code runs. According to the source in crates/monty/src/bytecode/vm/mod.rs (lines 63-78), it stores:
- The operand stack (
Vec<Value>) - The call frame list (
Vec<CallFrame>) - The exception stack
- The current instruction pointer (IP)
- The next external call ID counter
- An optional async scheduler for pending futures
When the VM pauses, VM::snapshot() consumes the live VM and moves these vectors directly into the VMSnapshot without cloning. This ownership transfer is critical for performance: reference counts on heap objects remain unchanged because the VM already owned them.
Snapshot and FutureSnapshot: Serializable Containers
While VMSnapshot captures the execution pointer, it does not include the heap or the compiled bytecode. The Snapshot<T> and FutureSnapshot<T> structs defined in crates/monty/src/run.rs (lines 14-27) serve as the top-level containers that bundle:
- The executor (compiled bytecode and intern tables)
- The heap (all Python objects referenced by the stack)
- The namespaces (global and local variable bindings)
- The VMSnapshot (execution state)
- A generic resource tracker
Tfor custom limits (time, memory, etc.)
Both structs derive serde::Serialize and serde::Deserialize with bounds requiring T: Serialize + DeserializeOwned, enabling them to be encoded to any serde-compatible format.
The Serialization Process: From Live VM to Binary Blob
Monty converts a running Python program into a storable binary format through a deterministic sequence of ownership transfers and encoding steps.
Detecting Pause Points with check_snapshot()
The VM does not snapshot on every instruction—that would be prohibitively expensive. Instead, VM::check_snapshot() in crates/monty/src/bytecode/vm/mod.rs (lines 47-55) determines whether the current FrameExit result warrants a pause. Snapshots are created only when the VM returns:
ExternalCall(calling a host function)OsCall(operating system interaction)ResolveFutures(awaiting async completion)
This selective approach ensures that pure Python code runs at full speed, while boundaries between the sandbox and the host are capture points.
Zero-Copy State Capture with snapshot()
When a pause is triggered, VM::snapshot() consumes the VM instance (taking self by value) and constructs a VMSnapshot. As implemented in crates/monty/src/bytecode/vm/mod.rs (lines 61-68), this method moves the operand stack, call frames, and exception stack vectors directly into the snapshot struct. Because these are moved rather than cloned, and because Value objects on the stack are reference-counted handles to the heap, no reference count adjustments are necessary. The snapshot now owns the exact same state the VM had at the pause point.
Serde Serialization and postcard Encoding
With the VMSnapshot prepared, Monty wraps it in a Snapshot<T> or FutureSnapshot<T> along with the executor, heap, and namespaces. The dump() method in crates/monty/src/run.rs (lines 99-108) serializes this container using postcard::to_allocvec, producing a compact, self-contained binary blob. The load() method (lines 110-119) reverses this process using postcard::from_bytes to reconstruct the Snapshot<T>.
Restoring Execution State
Deserialization reconstructs not just the data, but the exact execution context required to continue running Python code.
Reconstructing the VM with restore()
The Snapshot::run() or FutureSnapshot::resume() methods invoke VM::restore() with the stored VMSnapshot. As defined in crates/monty/src/bytecode/vm/mod.rs (lines 90-106), this method:
- Reconstructs each
CallFramefrom the serialized representation, relinking function IDs to theInternstable in the executor - Reinstates the operand stack, exception stack, and instruction pointer
- Restores the external call ID counter and async scheduler state
The result is a fully functional VM positioned at the exact bytecode instruction where it paused, with all heap objects and namespace bindings intact.
Code Examples
Caching a Compiled Program for Later Reuse
This example demonstrates serializing the MontyRun container itself, which includes the compiled bytecode and intern tables, allowing you to skip parsing on subsequent runs.
use monty::{MontyRun, MontyObject};
let runner = MontyRun::new(
"def inc(x): return x+1\ninc(a)".to_owned(),
"example.py",
vec!["a".to_owned()],
vec![], // no external functions
).unwrap();
// First execution (no snapshotting)
let result = runner.run_no_limits(vec![MontyObject::Int(41)]).unwrap();
assert_eq!(result, MontyObject::Int(42));
// Serialize the *runner* (parsed code, intern tables) for caching
let serialized = runner.dump().unwrap();
// Store `serialized` somewhere (file, DB, …)
// Later… reconstruct the runner without reparsing
let cached_runner = MontyRun::load(&serialized).unwrap();
let result2 = cached_runner.run_no_limits(vec![MontyObject::Int(7)]).unwrap();
assert_eq!(result2, MontyObject::Int(8));
Key implementation details: MontyRun::dump() is defined in crates/monty/src/run.rs at lines 99-108, while MontyRun::load() resides at lines 110-119.
Pausing at External Function Calls
When the VM encounters a host function, it returns control to the caller along with a snapshot that can be resumed later.
use monty::{MontyRun, MontyObject, ExternalResult, RunProgress};
// A function that will be supplied by the host
let external_funcs = vec!["host_print".to_string()];
let runner = MontyRun::new(
"def foo(): host_print('hello')\nfoo()".to_owned(),
"host_demo.py",
vec![],
external_funcs,
).unwrap();
// Start execution – it stops at the external call
let progress = runner.start(vec![]).unwrap(); // Returns RunProgress::FunctionCall
match progress {
RunProgress::FunctionCall(call) => {
// Host supplies the result (here we just return None)
let snapshot = call.snapshot; // This is a Snapshot<T>
let resumed = snapshot.run(
ExternalResult::Return(MontyObject::None),
&mut PrintWriter::Stdout
).unwrap();
// `resumed` is either Complete, another FunctionCall, or ResolveFutures
println!("Finished: {:?}", resumed);
}
_ => unreachable!(),
}
Key implementation details: Snapshot::run() is implemented in crates/monty/src/run.rs at lines 72-84, while VM::restore() is defined in crates/monty/src/bytecode/vm/mod.rs at lines 90-106.
Incrementally Resolving Async Futures
For asynchronous code, FutureSnapshot captures the scheduler state, allowing incremental resolution of pending futures.
use monty::{MontyRun, RunProgress, MontyObject};
let runner = MontyRun::new(
"import asyncio\nasync def work(): await asyncio.sleep(1)\nasyncio.run(work())".to_owned(),
"async_demo.py",
vec![],
vec![],
).unwrap();
let mut progress = runner.start(vec![]).unwrap(); // Pause at first future
while let RunProgress::ResolveFutures(state) = progress {
// Resolve a subset of pending futures (here we just mark all as done)
let results = state.pending_call_ids()
.iter()
.map(|&id| (id, MontyObject::None))
.collect();
progress = state.resume(results, &mut PrintWriter::Stdout).unwrap();
}
assert!(matches!(progress, RunProgress::Complete(_)));
Key implementation details: FutureSnapshot::run() is defined in crates/monty/src/run.rs at lines 50-57, with FutureSnapshot::resume() at lines 81-89.
Summary
- Zero-copy snapshots: Monty moves ownership of the VM's operand stack, call frames, and exception stack directly into
VMSnapshotwithout cloning, preserving reference counts. - Serde-enabled containers:
Snapshot<T>andFutureSnapshot<T>bundle the VM state with the heap, namespaces, and executor, implementingSerializeandDeserializefor cross-process portability. - Selective pausing: The VM only snapshots when encountering external calls or async boundaries, detected by
VM::check_snapshot()incrates/monty/src/bytecode/vm/mod.rs. - Deterministic restoration:
VM::restore()reconstructs call frames by relinking function IDs to intern tables, allowing execution to continue from the exact instruction pointer where it paused.
Frequently Asked Questions
What is the difference between VMSnapshot and Snapshot?
VMSnapshot is an internal struct defined in crates/monty/src/bytecode/vm/mod.rs that holds only the transient execution data—operand stack, call frames, instruction pointer, and exception state. Snapshot<T> is the public-facing container in crates/monty/src/run.rs that combines a VMSnapshot with the heap, namespaces, executor, and a generic resource tracker T, implementing serde traits for full serialization.
How does Monty achieve zero-copy serialization?
When VM::snapshot() is called, it consumes the VM instance by taking self and moves its internal vectors (operand stack, frames, exception stack) directly into the VMSnapshot struct without cloning. Because Value objects on the stack are reference-counted handles, this transfer preserves all reference counts and avoids expensive deep copies of heap objects, as implemented in crates/monty/src/bytecode/vm/mod.rs lines 61-68.
Can Monty serialize asynchronous execution state?
Yes, Monty supports serializing async state through FutureSnapshot<T>, which includes an optional Scheduler field inside VMSnapshot to track pending tasks. When the VM pauses at a ResolveFutures boundary, FutureSnapshot::resume() allows incremental resolution of futures, enabling the program to continue async execution after deserialization even if resumed in a different process.
What serialization format does Monty use?
Monty uses the postcard crate to encode Snapshot<T> and FutureSnapshot<T> into a compact, binary format. The dump() method in crates/monty/src/run.rs (lines 99-108) calls postcard::to_allocvec, while load() (lines 110-119) uses postcard::from_bytes for reconstruction, providing a space-efficient representation suitable for caching and network transmission.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →