Use mimalloc for request-path allocations

The production image is built against musl, whose allocator is slow and
takes a global lock. Profiling /json on a 1 CPU / 1 GB Alpine container
showed malloc/free at ~11% of total CPU, the largest user-space cost;
the rest is mostly the kernel network stack, already at one recvfrom
and one writev per request.

mimalloc alone, however, kept the freed ~96 MB MMDB buffer resident
after a database update (RSS stayed at ~197 MB in two of three swaps),
and MIMALLOC_PURGE_DELAY=0 fixes that only by giving back most of the
speedup. So blocks of 1 MiB or more, in practice only the MMDB file
contents, go to the system allocator, which unmaps them on free; RSS
returns to ~98 MB after every swap, as before.

Median of three alternating 20 s runs: /json across random IPs rose
from 131.6k to 142.4k rps (+8%), p50 latency 0.465ms -> 0.443ms; a
single IP rose from 124.5k to 127.4k rps (+2%). Steady-state RSS grows
by ~12 MB. Responses are byte-identical to before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetworkCats
2026-10-23 19:16:07 +08:00
co-authored by Claude Opus 5.5
parent c05b71e33b
commit 036a3fa061
4 changed files with 147 additions and 0 deletions
Generated
+19
View File
@@ -770,6 +770,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -837,6 +846,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -1287,6 +1305,7 @@ dependencies = [
"dotenvy",
"http-body-util",
"maxminddb",
"mimalloc",
"quick_cache",
"reqwest",
"serde",
+1
View File
@@ -20,6 +20,7 @@ urlencoding = "2"
tokio-util = "0.7"
static-files = "0.3.1"
quick_cache = "0.7"
mimalloc = "0.1.52"
[build-dependencies]
static-files = "0.3.1"
+122
View File
@@ -0,0 +1,122 @@
//! Global allocator: mimalloc for request-path allocations, the system
//! allocator for very large blocks.
//!
//! The production image is built against musl, whose allocator is slow and
//! takes a global lock; it was the largest user-space cost of serving `/json`.
//! mimalloc, however, keeps a freed ~100 MB MMDB buffer resident after a
//! database update, while musl unmaps it immediately. Routing large blocks to
//! the system allocator keeps the speed without holding a dead database.
use std::alloc::{GlobalAlloc, Layout, System};
use std::ptr;
use mimalloc::MiMalloc;
/// Blocks at least this large go to the system allocator. Far above anything
/// allocated per request, far below the MMDB file contents.
const LARGE_ALLOC_BYTES: usize = 1 << 20;
pub struct Allocator;
fn is_large(size: usize) -> bool {
size >= LARGE_ALLOC_BYTES
}
// SAFETY: every block is freed or resized by the allocator that produced it,
// since the choice depends only on the block size, which `dealloc` and
// `realloc` receive unchanged from the caller.
unsafe impl GlobalAlloc for Allocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
unsafe {
if is_large(layout.size()) {
System.alloc(layout)
} else {
MiMalloc.alloc(layout)
}
}
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
unsafe {
if is_large(layout.size()) {
System.alloc_zeroed(layout)
} else {
MiMalloc.alloc_zeroed(layout)
}
}
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe {
if is_large(layout.size()) {
System.dealloc(ptr, layout);
} else {
MiMalloc.dealloc(ptr, layout);
}
}
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
unsafe {
match (is_large(layout.size()), is_large(new_size)) {
(false, false) => MiMalloc.realloc(ptr, layout, new_size),
(true, true) => System.realloc(ptr, layout, new_size),
// Crossing the threshold moves the block to the other allocator.
_ => {
let new_layout = Layout::from_size_align_unchecked(new_size, layout.align());
let new_ptr = self.alloc(new_layout);
if !new_ptr.is_null() {
ptr::copy_nonoverlapping(ptr, new_ptr, layout.size().min(new_size));
self.dealloc(ptr, layout);
}
new_ptr
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn realloc_across_threshold_preserves_contents() {
let small = 64 * 1024;
let large = 4 * LARGE_ALLOC_BYTES;
let layout = Layout::from_size_align(small, 16).unwrap();
unsafe {
let p = Allocator.alloc(layout);
assert!(!p.is_null());
for i in 0..small {
*p.add(i) = i as u8;
}
let grown = Allocator.realloc(p, layout, large);
assert!(!grown.is_null());
assert_eq!(grown as usize % 16, 0);
for i in 0..small {
assert_eq!(*grown.add(i), i as u8);
}
let large_layout = Layout::from_size_align(large, 16).unwrap();
let shrunk = Allocator.realloc(grown, large_layout, small);
assert!(!shrunk.is_null());
for i in 0..small {
assert_eq!(*shrunk.add(i), i as u8);
}
Allocator.dealloc(shrunk, layout);
}
}
#[test]
fn alloc_zeroed_large_block_is_zeroed() {
let layout = Layout::from_size_align(2 * LARGE_ALLOC_BYTES, 8).unwrap();
unsafe {
let p = Allocator.alloc_zeroed(layout);
assert!(!p.is_null());
assert!((0..layout.size()).step_by(4096).all(|i| *p.add(i) == 0));
Allocator.dealloc(p, layout);
}
}
}
+5
View File
@@ -7,6 +7,11 @@ use tracing_subscriber::EnvFilter;
use rustyip::{config, db, handlers, routes, updater};
mod alloc;
#[global_allocator]
static GLOBAL: alloc::Allocator = alloc::Allocator;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();