43. Memory: mmap and a counting global allocator
43.1 Memory-mapped files
use memmap2::Mmap;
use std::fs::File;
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rust-guide-mmap.log");
let mut f = File::create(&path)?;
for i in 0..100_000 { writeln!(f, "event {i} status={}", if i % 97 == 0 { 500 } else { 200 })?; }
drop(f);
let file = File::open(&path)?;
// SAFETY: the mapping is read-only, and no other process truncates this file while it is mapped.
// If one did, reading the vanished pages would raise SIGBUS. That is why Mmap::map is `unsafe`.
let map = unsafe { Mmap::map(&file)? };
let bytes: &[u8] = ↦ // the file, as a byte slice, with no read() copies
let lines = bytes.iter().filter(|&&b| b == b'\n').count();
let errors = bytes.split(|&b| b == b'\n').filter(|l| l.ends_with(b"500")).count();
println!("{lines} lines, {errors} errors, {} bytes mapped", map.len());
std::fs::remove_file(path)
}
Pages are loaded lazily through page faults
(OS Ch 11),
so mapping a 10 GB log costs nothing until you touch it. Note the SAFETY
comment: Rust's &[u8] promises the bytes will not change, but the kernel
lets another process change or truncate the underlying file. That is the
reason Mmap::map is unsafe. C §57
covers msync and shared writable mappings.
43.2 A counting global allocator
Every Box, Vec, and String allocates through one global allocator,
which you can replace. Wrapping the system allocator to count allocations is
a cheap, always-on way to find allocation-heavy code paths:
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
static BYTES: AtomicUsize = AtomicUsize::new(0);
// SAFETY: we forward to System, which upholds GlobalAlloc's contract; we only add counters.
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
BYTES.fetch_add(layout.size(), Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn measure<T>(label: &str, f: impl FnOnce() -> T) -> T {
let (a0, b0) = (ALLOCS.load(Relaxed), BYTES.load(Relaxed));
let out = f();
println!("{label:<28} {:>6} allocs {:>9} bytes", ALLOCS.load(Relaxed) - a0, BYTES.load(Relaxed) - b0);
out
}
fn main() {
let words: Vec<String> = (0..10_000).map(|i| format!("w{i}")).collect();
measure("join via format! in a loop", || {
let mut s = String::new();
for w in &words { s = format!("{s}{w},"); } // allocates a new String every iteration
s.len()
});
measure("push_str with capacity", || {
let mut s = String::with_capacity(words.iter().map(|w| w.len() + 1).sum());
for w in &words { s.push_str(w); s.push(','); }
s.len()
});
}
join via format! in a loop 19998 allocs 868126828 bytes
push_str with capacity 1 allocs 58890 bytes
The first version copies the whole string on every iteration. It allocated
868 MB in total, with quadratic growth, to build a 58 KB result. This is
the same bug as s += x in a Python or Java loop, and the counting
allocator makes it obvious.
The same #[global_allocator] line is how you switch to mimalloc or
jemalloc in production (static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;).
The C guide's Ch 39
explains what such an allocator does internally.