Introduction
Rust has quietly become one of the most compelling languages for AI and ML workloads, especially where performance, memory safety, and production reliability matter most. Whether you're building inference servers that need to handle thousands of concurrent requests or tokenizers that must process millions of tokens per second, Rust for AI and ML: a practical guide for developers walks you through the ecosystem that's emerging around this problem space. Unlike interpreted languages, Rust gives you compiled binaries, predictable resource usage, and the kind of safety guarantees that make teams confident shipping models to production.
Candle and Burn for ML in Rust
Two frameworks have emerged as the primary workhorses for machine learning in Rust: Candle and Burn. Both take different philosophical approaches, but solve the core problem of making tensor operations ergonomic and performant.
Candle, maintained by Hugging Face, prioritizes simplicity and minimal dependencies. It's designed to feel close to PyTorch if you've used that before, but it runs on CPU or GPU without pulling in heavy runtime overhead. The real win with Candle is that you can ship a single binary that includes your model—no Python, no virtual environments, no runtime surprises in production.
Burn takes a different angle: it's built from the ground up as a native Rust framework with explicit support for both training and inference, plus backends that can target WASM, embedded systems, or GPU. If you need flexibility across platforms—say, inference on desktop, mobile, and server—Burn's architecture gives you that without major code rewrites.
Here's a practical example using Candle to load and run a model:
use candle::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::llama;
fn main() -> candle::Result<()> {
let device = Device::cuda_if_available(0)?;
// Load a pretrained model
let vb = VarBuilder::from_safetensors(
"model.safetensors",
candle::DType::F32,
&device,
)?;
// Create model instance
let model = llama::Model::new(&vb.pp("model"), &Default::default())?;
// Prepare input
let input = Tensor::new(&[[1, 2, 3, 4]], &device)?;
// Run inference
let output = model.forward(&input)?;
println!("Model output: {:?}", output);
Ok(())
}
In practice, we've used Candle for inference-heavy applications where latency is critical. The lack of dependency bloat means faster builds, smaller container images, and easier debugging when something goes wrong. Burn shines when your team needs to experiment with training loops or custom architectures—the type safety around tensor operations catches many errors at compile time rather than runtime.
Fast Tokenizers for LLMs
Tokenization is often the forgotten bottleneck in LLM pipelines. When you're processing thousands of documents or running inference at scale, splitting text into tokens becomes a legitimate performance problem. The Hugging Face tokenizers library, written in Rust, solves this with bindings for Python but the real engine is pure Rust.
If you're building your own inference pipeline, you have two options: use the Rust tokenizers crate directly or wrap the Python library. For performance-critical paths, the native Rust option is worth the setup cost.
use tokenizers::Tokenizer;
fn main() -> tokenizers::Result<()> {
// Load a tokenizer from Hugging Face
let tokenizer = Tokenizer::from_pretrained("gpt2", None)?;
let encoding = tokenizer.encode(
"The quick brown fox jumps over the lazy dog",
false,
)?;
println!("Tokens: {:?}", encoding.get_ids());
println!("Count: {}", encoding.get_ids().len());
Ok(())
}
One real-world scenario: we were building a document ingestion service that needed to tokenize millions of PDFs. Using the native Rust tokenizer in a multi-threaded Tokio runtime, we achieved 50K documents per second on modest hardware. The same workflow in pure Python topped out around 5K documents per second because of GIL contention. That's a 10x difference that justified rewriting the tokenization layer.
The tokenizers crate also handles edge cases like special token IDs, padding strategies, and attention masks—things you definitely don't want to implement yourself. It's well-tested, battle-hardened, and heavily used in production LLM applications.
Inference Servers
Building a production inference server requires more than just calling a model—you need request batching, concurrent inference without memory explosions, graceful degradation under load, and straightforward model reloading. Rust's async ecosystem makes this surprisingly clean.
Most teams use either a custom Tokio-based server or libraries like vLLM-rs, which port concepts from the Python vLLM but run natively. For maximum control and minimal dependencies, a hand-written Axum or Actix server is worth considering.
use axum::{
extract::Json,
http::StatusCode,
routing::post,
Router,
};
use std::sync::Arc;
use tokio::sync::Semaphore;
#[derive(serde::Deserialize)]
struct InferRequest {
prompt: String,
max_tokens: usize,
}
#[derive(serde::Serialize)]
struct InferResponse {
output: String,
tokens_generated: usize,
}
async fn infer(
Json(req): Json<InferRequest>,
semaphore: Arc<Semaphore>,
) -> Result<Json<InferResponse>, StatusCode> {
// Limit concurrent inference to prevent OOM
let _permit = semaphore
.acquire()
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
// Run model inference here
let output = format!("Generated: {}", req.prompt);
Ok(Json(InferResponse {
output,
tokens_generated: 10,
}))
}
#[tokio::main]
async fn main() {
let semaphore = Arc::new(Semaphore::new(4)); // Max 4 concurrent inferences
let app = Router::new()
.route("/infer", post(infer))
.with_state(semaphore);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
The semaphore pattern above is critical: GPU memory is fixed, so you can't just spawn unlimited concurrent tasks. By constraining how many inference calls run simultaneously, you guarantee predictable memory usage and avoid the cascading failures you see when a server gets overloaded.
In production, we've wrapped this with request queuing, health checks, and model versioning. Deploying this as a Docker container gives you a portable, single-binary inference endpoint that doesn't need Python or system libraries beyond glibc.
Python Bindings for Training Workflows
Here's the pragmatic truth: Rust is excellent for inference and deployment, but training large models still happens predominantly in PyTorch or JAX. Rather than forcing your team to retrain everything, a hybrid approach lets Rust handle the performance-critical parts while Python manages the training loop.
PyO3 is the standard crate for writing Python bindings in Rust. You define your high-performance logic in Rust, expose it via PyO3, and call it from Python like a normal module. This works beautifully for custom loss functions, data preprocessing, or model validation steps that bottleneck your training.
use pyo3::prelude::*;
#[pyclass]
struct FastTokenizer {
vocab_size: usize,
}
#[pymethods]
impl FastTokenizer {
#[new]
fn new(vocab_size: usize) -> Self {
FastTokenizer { vocab_size }
}
fn tokenize(&self, text: &str) -> Vec<u32> {
// Fast tokenization logic here
text.split_whitespace()
.map(|word| (word.len() as u32) % self.vocab_size as u32)
.collect()
}
}
#[pymodule]
fn fast_tokenizer_rs(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<FastTokenizer>()?;
Ok(())
}
You build this with Maturin, which handles the Python packaging plumbing. Then in your training script:
import fast_tokenizer_rs
tokenizer = fast_tokenizer_rs.FastTokenizer(vocab_size=50000)
tokens = tokenizer.tokenize("example text here")
We've used this pattern for custom augmentation pipelines that were too slow in pure Python, and for batch validation logic that needed to run thousands of times during training. The Rust code compiles to a native extension, so it's as fast as you'd get with a C library but way easier to write and maintain.
Common Mistakes
- Ignoring memory layout: GPU memory fragmentation is real. Allocating and freeing tensors in tight loops will fragment your VRAM. Preallocate buffers and reuse them.
- Mixing async and blocking operations: Calling blocking Rust code (like model inference) directly in a Tokio task without using
spawn_blockingwill tank throughput. Always offload heavy work. - Underestimating compilation time: Rust compiles slower than Python. Build times for projects with many dependencies can hit 5+ minutes. Plan your CI/CD accordingly and use incremental compilation aggressively.
- Shipping debug binaries to production: Unoptimized Rust is actually slower than Python for some workloads. Always compile with
--releaseand enable LTO in your Cargo.toml. - Trying to train from scratch: Don't rewrite your entire training pipeline in Rust. Use it for inference, preprocessing, and optimization. Keep PyTorch or JAX for the training loop.
Key Takeaways
- Candle and Burn bring production-grade ML to Rust with minimal runtime overhead—pick Candle for simplicity or Burn for cross-platform flexibility.
- Native Rust tokenizers outperform Python by 5-10x for large-scale text processing; use them in your critical paths.
- Build inference servers with Axum or Actix plus semaphores for concurrency control; you get a single binary without dependency bloat.
- Use PyO3 for hybrid Python-Rust workflows—let Rust handle performance bottlenecks while Python orchestrates training.
- Always profile first; Rust's safety and speed only matter if you're actually solving a real performance problem in your system.






Comments
Loading comments…