From 036a3fa0617b29c2c1f4c08c3db103bfc303e306 Mon Sep 17 00:00:00 2001 From: NetworkCats <210240595+NetworkCats@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:16:07 +0800 Subject: [PATCH] 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 --- Cargo.lock | 19 ++++++++ Cargo.toml | 1 + src/alloc.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 +++ 4 files changed, 147 insertions(+) create mode 100644 src/alloc.rs diff --git a/Cargo.lock b/Cargo.lock index db3b9ab..3496bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 53708fb..c4ba266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/alloc.rs b/src/alloc.rs new file mode 100644 index 0000000..4089766 --- /dev/null +++ b/src/alloc.rs @@ -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); + } + } +} diff --git a/src/main.rs b/src/main.rs index c65e0e0..d8b708c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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();