Python is where almost all serious ML work happens. PyTorch, Hugging Face Transformers, vLLM, LangChain — the ecosystem is deep and practically irreplaceable. But when you try to take that Python code from a Jupyter notebook to a production inference endpoint that needs to handle hundreds of concurrent requests at low latency, you run into a set of structural problems that don't go away just by tuning your uvicorn workers. The Rust sidecar pattern is one way engineers have been addressing this — not by rewriting their models in Rust, but by carving out the performance-critical serving path and running it in a Rust process or extension alongside their Python inference code.
What Python Gets Wrong in Production Serving
The Global Interpreter Lock is the most discussed issue, and it's real. CPython only allows one thread to execute Python bytecode at a time. For ML serving, this matters most during request handling and preprocessing, not during GPU compute — the GPU runs independently of the GIL. But if you're running tokenization, input validation, batching logic, or output post-processing in Python threads, they serialize. You can sidestep this with multiprocessing, but each worker process loads its own copy of the model weights. A 7B-parameter model at float16 runs around 14GB; duplicating that across four processes is not practical on a standard GPU instance.
Python 3.13 introduced free-threaded mode as an experimental build, and Python 3.14 (released October 2025) made it more viable — but the catch is that any C extension compiled without Py_mod_gil support will silently re-enable the GIL for the whole interpreter. Most ML libraries carry heavy C extension stacks. In practice, free-threaded Python for ML serving is still an edge-case configuration, not a general recommendation.
Beyond threading, Python's cold-start problem in serverless or container-based deployments is measurable. Importing torch, loading a tokenizer, and warming up CUDA kernels can take 10–60 seconds depending on model size and hardware — and that entire chain runs synchronously at process startup. This makes auto-scaling painful: you can't spin up an instance and have it ready to serve within a second or two the way a stateless Go or Rust service can.
Packaging is another genuine friction point. Python dependency trees for ML projects are large, brittle, and platform-specific. Getting a reproducible, minimal container image for a Python ML service typically involves pinning dozens of transitive dependencies, choosing between pip, poetry, uv, and navigating CUDA version compatibility. Rust binaries, by contrast, compile to a single statically linked executable with no runtime dependency on the system Python.
What the Sidecar Pattern Actually Looks Like
The core idea is process or module separation: keep your model loading, forward pass, and ML-specific logic in Python, but move request handling, connection management, batching, tokenization, and any other hot-path work into Rust. There are three main integration points, with different tradeoffs on each.
Separate process + IPC. This is what Hugging Face's Text Generation Inference (TGI) implements. TGI uses a three-tier architecture: a Rust HTTP/gRPC router handles all incoming client requests, performs tokenization in dedicated Rust threads, manages continuous batching, and forwards inference requests over gRPC to a Python server process that runs the actual PyTorch forward pass. The two processes communicate over a Unix Domain Socket at /tmp/text-generation-server by default, which avoids network stack overhead while keeping process boundaries clean. The Rust router and Python inference server can crash independently — a panic in the request-handling layer doesn't bring down the model process, and vice versa.
The gRPC interface between them defines operations like Prefill, Decode, FilterBatch, and Warmup. This is typed, versioned contract between the two sides, which makes it easier to update them separately.
PyO3 in-process extension. If process isolation is too much overhead for your use case, PyO3 lets you compile Rust code as a native Python extension. Your Python code calls into the Rust functions directly via the CPython extension API, with approximately 0.2 microseconds of FFI overhead per call. Hugging Face's tokenizers library is the canonical example: tokenization logic is written in Rust, compiled to a .so via maturin, and imported like any Python package. The speedup is primarily from parallelism — Rust tokenization can use all available CPU cores with rayon while Python's GIL would otherwise prevent that. The encode_batch() call in particular runs Rust threads in parallel, giving a substantial throughput gain over calling a Python tokenizer in a loop.
# Scaffold a PyO3 extension
cargo new --lib my_preprocessor
# In Cargo.toml:
# [lib] crate-type = ["cdylib"]
# [dependencies] pyo3 = { version = "0.28", features = ["extension-module"] }
# Build and install into current Python env
maturin develop --release
use pyo3::prelude::*;
#[pyfunction]
fn batch_tokenize(texts: Vec<String>) -> PyResult<Vec<Vec<u32>>> {
// rayon parallel iterator here — no GIL involved
Ok(texts.into_iter().map(|t| tokenize_one(&t)).collect())
}
#[pymodule]
fn my_preprocessor(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(batch_tokenize, m)?)?;
Ok(())
}
FFI via shared memory. For latency-sensitive scenarios where even 0.2µs FFI overhead matters, some teams use shared memory buffers (via mmap or posix_shm) to pass tensors between a Rust process and a Python process without copying. This is more complex to implement and requires careful synchronization, but avoids both the serialization cost of gRPC and the FFI overhead of PyO3. It's an uncommon pattern outside of specialized inference infrastructure teams.
The
maturinbuild tool compiles PyO3 extensions to platform wheels and can publish them to PyPI. Using theabi3-py39feature flag builds a single wheel that runs on Python 3.9 and later, rather than separate per-version builds. For CI, thePyO3/maturin-action@v1GitHub Action handles cross-compilation and manylinux compliance automatically — which is the main packaging win over distributing a raw.sofile.
What Goes in Rust, What Stays in Python
The separation isn't arbitrary — it follows where Python's structural weaknesses actually hurt you.
Put in Rust: HTTP and gRPC server logic, request validation and schema enforcement, tokenization and detokenization, request batching and queue management, connection pooling, rate limiting, metrics collection, and any CPU-bound preprocessing that benefits from true parallelism (text normalization, feature hashing, JSON parsing at high throughput).
Keep in Python: model weight loading, forward pass execution, GPU memory management, anything that calls into PyTorch or CUDA kernels directly, custom training code, and evaluation pipelines. Also keep in Python anything that relies on Hugging Face model configs, custom attention implementations, or model-specific pre/post-processing that changes per-model.
The reason tokenization specifically belongs in Rust is that it's CPU-bound, parallelizable, and runs on every request — it's exactly the kind of hot-path code that the GIL penalizes most. The reason forward passes stay in Python is that they're running on the GPU, PyTorch's CUDA integration is mature and deeply Python-specific, and there's no Rust equivalent that handles arbitrary model architectures from the HF Hub.
The Costs You Should Expect
Two languages means two build systems. Your CI pipeline needs a Rust toolchain, Cargo dependency management, and maturin or your own build scripts on top of whatever Python packaging you already have. Build times increase — Rust compile times are not trivial, especially with rayon or tokio in the dependency tree. A cold Cargo build on a modest CI runner can take several minutes; incremental builds are faster but still add friction compared to a pure Python project.
Debugging across the language boundary is harder. A panic in Rust propagates back to Python as a pyo3::panic exception, which gives you a stack trace from the Rust side but not much context from Python. With the separate-process pattern, you're debugging two logs, two process states, and a gRPC protocol layer between them.
There's also a hiring and onboarding cost. Most ML engineers are comfortable with Python and uncomfortable with ownership, lifetimes, and Rust's borrow checker. If the Rust sidecar is written by one engineer who leaves, it can become a black box. This is a real organizational risk, not just a technical one.
The performance gains are genuine, but claims of "10x improvements" often reflect cherry-picked benchmarks. For tokenization specifically, moving from Python to Rust can yield significant throughput gains on batch workloads because you get real parallelism. For end-to-end inference latency on GPU-bound workloads, the gain is narrower — the model's forward pass dominates, and the sidecar only addresses the overhead around it. If your p99 latency is 850ms and 800ms of that is GPU time, shaving 50ms off the serving layer helps but doesn't change the order of magnitude.
The pattern makes most sense when your serving layer overhead is a measurable fraction of total latency, when you need high concurrency with tight memory constraints, or when you're already dealing with packaging complexity that a compiled Rust binary would actually simplify. It's not a default architecture — it's a targeted fix for specific deployment constraints.
Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.
Top comments (0)