Skip to content

Custom User Models & Process Workers

SnoozeML supports custom user models at runtime via a process-isolated worker backend (worker-backend).

Instead of compiling custom deep learning architectures into the Rust binary or linking against libpython with PyO3, SnoozeML places the plugin boundary at a child process communicating over a high-performance binary pipe protocol.


Trade-Offs: Native Backends vs. Custom Python Workers

DimensionNative Engines (ort, candle, hessboost)Custom Python Workers (worker-backend)
Primary GoalMaximum throughput and ultra-low latencyMaximum flexibility and framework compatibility
Cold Start LatencyFast (tens to hundreds of milliseconds)Slow (in the seconds magnitude, 2s–10s+)
Inference OverheadZero IPC; in-process memory pointersBinary pipe IPC framing over stdin/stdout
Runtime DependenciesSelf-contained single binary (no Python needed)External Python interpreter + installed libraries
Scale-to-ZeroIn-process memory drop / VRAM evictionProcess termination (100% OS RAM/VRAM return)
Recommended UsageDefault / First ChoiceLast Resort (when native formats are not viable)

Why Process Isolation?

SnoozeML PropertyBenefit with Process Workers
Scale-to-Zero & LRU EvictionTerminating the child process guarantees 100% reclamation of GPU VRAM and host RAM by the OS. In-process PyTorch allocators frequently fragment or retain CUDA contexts.
Reactive OOM RecoveryMemory errors (torch.cuda.OutOfMemoryError or MemoryError) are mapped to InferenceError::OutOfMemory. SnoozeML automatically triggers LRU eviction and retries the batch.
Zero-GIL AutoscalingEach replica runs in its own process interpreter. Concurrency scales across CPU cores and GPU streams without GIL contention.
Zero-Panic StabilityCrashes or segfaults inside custom native extensions only kill that worker; the daemon stays up and gracefully respawns the replica.
Single-Binary PortabilityNo compile-time Python or libpython dependencies. The single snooze binary serves custom models whenever Python is available.
Dynamic Micro-BatchingUnchanged. The Rust batcher collates concurrent requests along Axis(0). The worker only ever receives fully collated batches.

Creating a Custom Worker Model

To serve a custom model, place a directory in models/ containing snooze_model.py:

models/my-custom-net/
├── snooze_model.py # Required entrypoint
├── model.safetensors # Weights or assets
└── snooze_model.json # Optional manifest

Python Entrypoint (snooze_model.py)

A worker model is duck-typed: it defines a Model class with load and predict methods. No snoozeml import is required:

import numpy as np
import torch
from safetensors.torch import load_file
class Model:
def load(self, model_dir: str, device: str) -> None:
"""Load weights and initialize the model on the requested device."""
self.device = torch.device(device)
# Load weights from model_dir
# self.net = MyPyTorchNet().to(self.device).eval()
def predict(self, inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
"""Perform inference on the collated batch (batch on Axis 0)."""
x = torch.from_numpy(inputs["input"]).to(self.device)
with torch.no_grad():
output = x * 2.0 + 1.0 # Example transformation
return {"output": output.cpu().numpy()}

Optional Manifest (snooze_model.json)

To customize interpreter paths, class names, timeouts, or environment variables, add snooze_model.json:

{
"python": ".venv/bin/python",
"entrypoint": "snooze_model.py",
"class": "Model",
"load_timeout_secs": 300,
"request_timeout_secs": 60,
"tensor_format": "numpy",
"env": {
"OMP_NUM_THREADS": "1"
}
}

Manifest Fields

FieldTypeDefaultDescription
pythonstringAutoInterpreter executable (resolved relative to model dir, .venv, SNOOZE_PYTHON, or python3).
entrypointstring"snooze_model.py"Python file containing the model class.
classstring"Model"Name of the model class.
load_timeout_secsinteger300Maximum seconds allowed for load() before killing the worker.
request_timeout_secsinteger60Maximum seconds allowed for a single batched predict() call.
tensor_formatstring"numpy"Format delivered to predict: "numpy" or "raw" (RawTensor without numpy).
envobject{}Key-value environment variables injected into the child process.

Configuration in models.toml

SnoozeML auto-discovers worker model directories containing snooze_model.py. You can also configure them explicitly in models.toml:

[[models]]
id = "custom-net"
path = "models/my-custom-net"
device = "cuda"
max_batch_size = 16
max_batch_delay_ms = 10
idle_timeout_secs = 60

When device is "cuda", "cpu", "mps", or "cuda:N", it is passed directly to load(model_dir, device).


Local Smoke Testing CLI

You can smoke-test your custom model locally without running the daemon using --check:

Terminal window
python -m snoozeml.worker --check models/my-custom-net --input input:4x3 --device cpu

Output:

OK: model loaded on 'cpu'
output 'output': shape=[4, 3] first=[3.0, 5.0, 7.0]

Wire Protocol (v1)

Communication between the daemon and workers flows across stdin and stdout using length-prefixed frames:

[u32 LE frame_len][u8 msg_type][u32 LE header_len][header JSON][raw little-endian f32 bytes...]
Type IDNameDirectionHeader SchemaPayload
1READYWorker → Host{"protocol": 1, "info": { ... }}Empty
2INFERHost → Worker{"tensors": [{"name": "...", "shape": [...], "dtype": "f32"}]}Concatenated LE f32
3RESULTWorker → Host{"tensors": [{"name": "...", "shape": [...], "dtype": "f32"}]}Concatenated LE f32
4ERRORWorker → Host`{“kind”: “oom”“invalid_input”
5SHUTDOWNHost → Worker{}Empty

The worker duplicates stdin/stdout file descriptors for the protocol and redirects standard output to standard error, ensuring user print() statements and C extension messages never corrupt the binary stream.


Real-World Example: Amazon Chronos-Bolt

For a complete, real-world example demonstrating how to serve Amazon Science’s Chronos-Bolt zero-shot time series foundation model using custom dependencies (chronos-forecasting, torch), see: