When the default allocator slows you down
You just deployed a high-throughput web service. The CPU usage looks efficient. Latency is low under light load. Then you ramp up concurrency and the profiler screams. The bottleneck isn't your business logic. It's memory allocation. Threads are queuing up waiting for the default allocator to hand out memory. Frame times jitter. Throughput plateaus. The allocator is fighting itself.
Rust ships with a default memory allocator. On Linux, the standard library often links jemalloc by default. On Windows, it uses the system allocator. The default works fine for most applications. It handles malloc and free calls safely. It integrates with the standard library types like Box, Vec, and HashMap.
The default allocator becomes a liability when contention spikes. Third-party allocators like mimalloc and jemalloc optimize for specific patterns. They reduce lock contention. They minimize fragmentation. They can drop in as a replacement with a single attribute.
How global allocation works
Rust's ownership system manages memory lifetimes. The allocator manages the raw heap. Every time you create a Box or push to a Vec, the standard library calls the global allocator.
The global allocator is a static variable marked with #[global_allocator]. This variable must implement the GlobalAlloc trait. The trait defines two methods: alloc and dealloc. alloc takes a Layout describing size and alignment, and returns a pointer. dealloc takes a pointer and Layout, and frees the memory.
Think of the default allocator like a single librarian in a massive library. Every thread that needs memory asks the librarian. The librarian checks the catalog, walks to the shelf, grabs the book, and hands it over. If ten threads ask at once, they all queue up. The librarian becomes a bottleneck.
Third-party allocators change the layout. mimalloc and jemalloc give each thread its own small cache of pre-allocated memory. Threads grab from their own cache instantly. They only talk to the central librarian when the cache runs empty. This reduces contention. Allocation becomes faster and more predictable.
The #[global_allocator] attribute tells the linker to use your static variable as the process-wide allocator. The standard library resolves all allocation calls to this symbol. You don't need to change your code. Box::new and Vec::push automatically use the new allocator.
Minimal setup with mimalloc
Start with mimalloc. It is a modern allocator that often wins in benchmarks for latency and throughput. It requires minimal configuration.
Add the dependency to Cargo.toml. Use default-features = false to avoid pulling in unnecessary build dependencies. The community convention is to be explicit about features for allocators to prevent version conflicts with libc or other system libraries.
[dependencies]
mimalloc = { version = "0.1", default-features = false }
Define the allocator in your binary entry point. The static variable must implement GlobalAlloc. mimalloc::MiMalloc implements this trait.
// src/main.rs
use mimalloc::MiMalloc;
/// Replace the default allocator with mimalloc for better multi-threaded performance.
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
// Any heap allocation here goes through mimalloc.
let _data = vec![0u8; 1024];
}
The #[global_allocator] attribute marks GLOBAL as the process-wide allocator. The variable name doesn't matter to the compiler. GLOBAL, ALLOC, or MIMALLOC all work. The convention is to use a clear name like GLOBAL so other developers know what the static does.
One attribute, one static. That's the contract.
Feature flags and cross-platform safety
Real projects often need to support multiple allocators. You might want mimalloc for performance but fall back to the default for debugging. Or you might want jemalloc on Linux but not on Windows.
Feature flags let you switch allocators at build time. Define optional dependencies and feature flags in Cargo.toml.
[dependencies]
mimalloc = { version = "0.1", default-features = false, optional = true }
tikv-jemallocator = { version = "0.5", optional = true }
[features]
mimalloc = ["dep:mimalloc"]
jemalloc = ["dep:tikv-jemallocator"]
The crate for jemalloc is named tikv-jemallocator. The struct inside is Jemalloc. This naming quirk trips up many developers. The crate name comes from the TiKV project, which maintains the Rust bindings. The community convention is to alias the import or just use the full path.
In main.rs, use #[cfg] attributes to select the allocator based on features and target platform.
// src/main.rs
#[cfg(feature = "mimalloc")]
#[global_allocator]
/// Use mimalloc when the feature is enabled.
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[cfg(all(feature = "jemalloc", not(target_env = "msvc")))]
#[global_allocator]
/// Use jemalloc on non-Windows targets. jemalloc does not support MSVC.
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
fn main() {
// Allocation uses the selected allocator.
let _data = vec![0u8; 1024];
}
The #[cfg(all(feature = "jemalloc", not(target_env = "msvc")))] guard is critical. jemalloc does not compile on Windows with the MSVC toolchain. If you enable the feature on Windows, the build fails. The guard ensures jemalloc is only used when the platform supports it.
Feature flags make the allocator a deployment choice, not a code fork.
Pitfalls and compiler errors
You can only have one global allocator per binary. If you accidentally enable two features that both define #[global_allocator], the build fails. The compiler rejects this with "global allocator has already been set".
error: global allocator has already been set
--> src/main.rs:10:1
|
10 | #[global_allocator]
| ^^^^^^^^^^^^^^^^^^^
This error happens when two #[global_allocator] attributes resolve to the same compilation unit. The compiler enforces a single allocator to prevent undefined behavior. The linker would otherwise have no way to resolve conflicting symbols.
Another pitfall is jemalloc on Windows. If you forget the not(target_env = "msvc") guard, the build fails with a compilation error from the C code inside jemalloc. The error message mentions missing headers or unsupported architecture. Always guard jemalloc against MSVC.
A third pitfall is mimalloc security features. mimalloc has a secure mode that adds randomization and hardening. This mode is disabled by default. If you need security hardening, enable the secure feature in Cargo.toml. The convention is to enable security features explicitly rather than relying on defaults.
The compiler enforces a single allocator. Respect that boundary.
Building the Rust compiler with jemalloc
If you are building the Rust compiler itself, you can enable jemalloc via bootstrap.toml. This is useful for reducing memory usage during compiler development.
Create or edit bootstrap.toml in the compiler source directory.
[build]
rust.jemalloc = true
Run ./x.py build. The bootstrap script configures the build to link jemalloc into the compiler binaries. This reduces memory fragmentation during compilation. The compiler uses jemalloc for its own internal allocations.
This configuration is specific to building rustc. It does not affect the standard library or your application code.
Decision: which allocator to use
Profile first. Swap allocators only when the data tells you to.
Use the default allocator when allocation is not a measured bottleneck; the standard library's choice is safe and requires zero configuration.
Use mimalloc when profiling shows allocation latency is the bottleneck in a multi-threaded application; it provides fast thread-local caches and often reduces memory footprint.
Use jemalloc when you are building a long-running server that suffers from memory fragmentation; its arena-based design handles churn better than many alternatives.
Use the system allocator when deploying to constrained environments or platforms where linking a custom allocator adds unacceptable binary size or complexity.
Profile first. Swap allocators only when the data tells you to.