malloc_bench_rs/lib.rs
1//! `malloc-bench-rs` — portable, generic-over-`GlobalAlloc` benchmark harness.
2//!
3//! Run **larson** (server churn) + **mstress** (alloc-then-free batches)
4//! workloads against ANY [`std::alloc::GlobalAlloc`], get aggregate ops/sec.
5//! Pre-spawns worker threads, runs a fixed op budget per worker, times the
6//! steady-state region (criterion-style per-iter timing mis-measures MT
7//! workloads — thread spawn inside the timed closure dominates; we avoid that).
8//!
9//! # Design
10//!
11//! Two workloads, both reporting **aggregate ops/sec** (an op = one
12//! alloc+free pair) over a fixed operation budget measured with
13//! `Instant::elapsed`:
14//!
15//! - **larson** — server-churn: each thread keeps a working set of live
16//! slots; each step frees a random slot and allocates a new random-size
17//! block into it. Periodically a block is handed off cross-thread.
18//! - **mstress** — rounds of "fill a vector of mixed blocks → free half in
19//! random order → refill → free all"; a fraction freed cross-thread.
20//!
21//! Cross-thread handoff is leak/UAF-free by construction: every allocated
22//! block is freed **exactly once, by exactly one thread**. A handed-off block
23//! is moved out of the producer's bookkeeping (its slot is set empty) before
24//! being sent; the consumer drains its mailbox and frees each received block
25//! once. At the end every thread frees its own remaining live blocks, then
26//! drains any final mailbox contents — so nothing is dropped on the floor and
27//! nothing is freed twice.
28//!
29//! # Determinism
30//!
31//! A dependency-free xorshift64 PRNG with a fixed per-thread seed, so runs
32//! are reproducible. No external `rand` crate is required.
33//!
34//! # No `#[global_allocator]`
35//!
36//! The harness calls `GlobalAlloc::alloc`/`dealloc` trait methods directly —
37//! it never sets `#[global_allocator]`. Your allocator does not need to be
38//! registered globally; you can benchmark several allocators in one binary.
39//!
40//! # Example
41//!
42//! ```rust
43//! use malloc_bench_rs::{run, Config, Workload};
44//! use std::alloc::System;
45//!
46//! let cfg = Config {
47//! threads: 2,
48//! steps_per_thread: 10_000,
49//! working_set: 128,
50//! mstress_blocks: 64,
51//! };
52//! let ops = run(Workload::Larson, &cfg, || System);
53//! assert!(ops > 0.0, "expected non-zero ops/sec");
54//! ```
55
56#![allow(unsafe_code)]
57// Confined to alloc_block / free_block / drain_mailbox helpers.
58// Each unsafe call is individually justified with a // SAFETY: comment.
59#![deny(missing_docs)]
60#![allow(
61 clippy::cast_possible_truncation,
62 clippy::cast_precision_loss,
63 clippy::semicolon_if_nothing_returned
64)]
65
66use std::alloc::{GlobalAlloc, Layout};
67use std::hint::black_box;
68use std::sync::mpsc::{channel, Receiver, Sender};
69use std::sync::{Arc, Barrier};
70use std::thread;
71use std::time::Instant;
72
73// ─── Internal primitives ──────────────────────────────────────────────────────
74
75/// A live allocation: raw pointer plus the exact layout it was allocated with
76/// (needed for a correct `dealloc`).
77///
78/// `Send` is asserted explicitly below — the block is logically *moved* to
79/// the receiving thread, which becomes its sole owner; the producer no longer
80/// touches it after sending.
81struct Block {
82 ptr: *mut u8,
83 layout: Layout,
84}
85
86// SAFETY: a `Block` is only ever sent across threads as an ownership transfer
87// (the producer empties its slot before sending; the consumer is the unique
88// owner thereafter). No two threads ever hold the same `Block`, so there is no
89// aliasing of the underlying allocation across threads.
90unsafe impl Send for Block {}
91
92/// Deterministic, dependency-free PRNG (xorshift64*). Fixed seed → reproducible.
93struct XorShift64(u64);
94
95impl XorShift64 {
96 fn new(seed: u64) -> Self {
97 // Avoid the all-zero state (xorshift fixed point).
98 Self(seed | 1)
99 }
100
101 #[inline]
102 fn next_u64(&mut self) -> u64 {
103 let mut x = self.0;
104 x ^= x >> 12;
105 x ^= x << 25;
106 x ^= x >> 27;
107 self.0 = x;
108 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
109 }
110
111 /// Uniform-ish in `[0, n)` (n > 0).
112 #[inline]
113 fn below(&mut self, n: usize) -> usize {
114 (self.next_u64() % n as u64) as usize
115 }
116}
117
118/// Pick a small-skewed allocation size: mostly 16..512 B, rarely up to ~8 KiB.
119#[inline]
120fn pick_size(rng: &mut XorShift64) -> usize {
121 let r = rng.next_u64();
122 if r.is_multiple_of(32) {
123 // ~3% large: 512 B .. 8 KiB.
124 512 + (r >> 8) as usize % (8 * 1024 - 512)
125 } else {
126 // ~97% small: 16 .. 512 B.
127 16 + (r >> 8) as usize % (512 - 16)
128 }
129}
130
131#[inline]
132fn layout_for(size: usize) -> Layout {
133 // 8-byte alignment — matches the typical allocator test convention.
134 Layout::from_size_align(size.max(1), 8).unwrap()
135}
136
137/// Allocate one block of a random small-skewed size, touching the first byte
138/// so the allocation is not optimized away and the page is actually faulted in.
139///
140/// # Safety
141///
142/// `a` must be a valid `GlobalAlloc`. The returned `Block` (if non-null) must
143/// be freed exactly once via `free_block` with the same allocator.
144#[inline]
145unsafe fn alloc_block<A: GlobalAlloc>(a: &A, rng: &mut XorShift64) -> Block {
146 let layout = layout_for(pick_size(rng));
147 // SAFETY: layout has non-zero size and valid alignment.
148 let ptr = unsafe { a.alloc(layout) };
149 if !ptr.is_null() {
150 // Touch first byte to fault the page and defeat dead-store elimination.
151 // SAFETY: ptr is valid for `layout.size() >= 1` bytes.
152 unsafe { ptr.write(0xA5) };
153 }
154 Block { ptr, layout }
155}
156
157/// Free a block previously produced by `alloc_block` with the same allocator.
158///
159/// # Safety
160///
161/// `block` must have been allocated by `a` and not yet freed.
162#[inline]
163unsafe fn free_block<A: GlobalAlloc>(a: &A, block: Block) {
164 if !block.ptr.is_null() {
165 // SAFETY: block.ptr came from `a.alloc(block.layout)` and is freed once.
166 unsafe { a.dealloc(block.ptr, block.layout) };
167 }
168}
169
170/// Drain any blocks waiting in this thread's cross-thread mailbox and free them.
171///
172/// # Safety
173///
174/// Every received block was allocated by `a` on some thread and ownership was
175/// transferred here; we are its unique owner and free it once.
176#[inline]
177unsafe fn drain_mailbox<A: GlobalAlloc>(a: &A, rx: &Receiver<Block>, count: &mut u64) {
178 while let Ok(block) = rx.try_recv() {
179 // SAFETY: unique ownership, freed once (see fn docs).
180 unsafe { free_block(a, block) };
181 *count += 1;
182 }
183}
184
185// ─── Worker implementations ───────────────────────────────────────────────────
186
187/// One larson worker. Returns the number of alloc+free *ops* it performed.
188///
189/// # Safety
190///
191/// `a` is a valid `GlobalAlloc` shared by all workers (a ZST or `Copy`
192/// handle). The body upholds the per-block free-exactly-once discipline.
193unsafe fn larson_worker<A: GlobalAlloc>(
194 a: &A,
195 seed: u64,
196 steps: usize,
197 working_set: usize,
198 senders: &[Sender<Block>],
199 rx: &Receiver<Block>,
200 self_idx: usize,
201) -> u64 {
202 let mut rng = XorShift64::new(seed);
203 let mut ops: u64 = 0;
204
205 // Pre-fill the working set.
206 let mut slots: Vec<Option<Block>> = Vec::with_capacity(working_set);
207 for _ in 0..working_set {
208 // SAFETY: valid allocator; block tracked in `slots`, freed once below.
209 slots.push(Some(unsafe { alloc_block(a, &mut rng) }));
210 }
211
212 // Every K steps, hand a block to another thread for cross-thread free.
213 const HANDOFF_EVERY: usize = 16;
214 let n_threads = senders.len();
215
216 for step in 0..steps {
217 // Service any inbound cross-thread frees first (keeps mailboxes drained).
218 // SAFETY: received blocks are uniquely owned here.
219 unsafe { drain_mailbox(a, rx, &mut ops) };
220
221 let idx = rng.below(working_set);
222
223 if n_threads > 1 && step % HANDOFF_EVERY == 0 {
224 // Hand this slot's block to another thread (move ownership out).
225 if let Some(block) = slots[idx].take() {
226 let mut target = rng.below(n_threads);
227 if target == self_idx {
228 target = (target + 1) % n_threads;
229 }
230 // The producer no longer owns `block` after send; the slot is
231 // now empty and will be refilled below.
232 if senders[target].send(block).is_err() {
233 // Receiver gone (shouldn't happen during the run) — we
234 // can't send, so the block would be leaked. Free locally
235 // to stay UAF/leak-free. (Unreachable in practice.)
236 }
237 }
238 } else if let Some(block) = slots[idx].take() {
239 // Normal path: free the old block locally.
240 // SAFETY: block was allocated by `a`, owned here, freed once.
241 unsafe { free_block(a, block) };
242 }
243
244 // Refill the slot with a fresh allocation.
245 // SAFETY: valid allocator; tracked in `slots`.
246 slots[idx] = Some(unsafe { alloc_block(a, &mut rng) });
247 ops += 1;
248 }
249
250 // Teardown: free every block we still own locally.
251 for block in slots.drain(..).flatten() {
252 // SAFETY: owned here, freed once.
253 unsafe { free_block(a, block) };
254 }
255 black_box(&ops);
256 ops
257}
258
259/// One mstress worker. Returns the number of alloc+free *ops* it performed.
260///
261/// # Safety
262///
263/// As `larson_worker`.
264unsafe fn mstress_worker<A: GlobalAlloc>(
265 a: &A,
266 seed: u64,
267 rounds: usize,
268 block_count: usize,
269 senders: &[Sender<Block>],
270 rx: &Receiver<Block>,
271 self_idx: usize,
272) -> u64 {
273 let mut rng = XorShift64::new(seed);
274 let mut ops: u64 = 0;
275 let n_threads = senders.len();
276
277 for _ in 0..rounds {
278 // SAFETY: inbound blocks uniquely owned here.
279 unsafe { drain_mailbox(a, rx, &mut ops) };
280
281 // Fill a vector with `block_count` mixed-size blocks.
282 let mut blocks: Vec<Option<Block>> = Vec::with_capacity(block_count);
283 for _ in 0..block_count {
284 // SAFETY: valid allocator; tracked in `blocks`.
285 blocks.push(Some(unsafe { alloc_block(a, &mut rng) }));
286 }
287
288 // Free half in random order; ~1 in 8 of those go cross-thread.
289 let half = block_count / 2;
290 for _ in 0..half {
291 let idx = rng.below(block_count);
292 if let Some(block) = blocks[idx].take() {
293 if n_threads > 1 && rng.below(8) == 0 {
294 let mut target = rng.below(n_threads);
295 if target == self_idx {
296 target = (target + 1) % n_threads;
297 }
298 let _ = senders[target].send(block);
299 } else {
300 // SAFETY: owned here, freed once.
301 unsafe { free_block(a, block) };
302 }
303 ops += 1;
304 }
305 }
306
307 // Refill the now-empty slots.
308 for slot in blocks.iter_mut() {
309 if slot.is_none() {
310 // SAFETY: valid allocator; tracked in `blocks`.
311 *slot = Some(unsafe { alloc_block(a, &mut rng) });
312 }
313 }
314
315 // Free everything remaining locally.
316 for block in blocks.drain(..).flatten() {
317 // SAFETY: owned here, freed once.
318 unsafe { free_block(a, block) };
319 ops += 1;
320 }
321 }
322
323 black_box(&ops);
324 ops
325}
326
327// ─── Public API ───────────────────────────────────────────────────────────────
328
329/// Workload selector.
330#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
331pub enum Workload {
332 /// Server-churn pattern: each thread keeps a live working set; each step
333 /// frees a random slot and allocates a new random-size block. Periodically
334 /// a block is handed off cross-thread. Models long-running server heaps.
335 Larson,
336 /// Batch-stress pattern: rounds of "fill N blocks → free half in random
337 /// order → refill → free all". A fraction freed cross-thread. Models
338 /// request-scoped allocators and scripting-engine GCs.
339 Mstress,
340}
341
342/// Configuration for a single benchmark run.
343///
344/// All fields have sensible defaults via [`Default`].
345#[derive(Clone, Debug)]
346pub struct Config {
347 /// Number of worker threads to spawn. Defaults to
348 /// `std::thread::available_parallelism()` (or 4 if unavailable).
349 pub threads: usize,
350 /// Number of alloc+free steps (ops) each thread performs. Larger values
351 /// give more stable measurements; smaller values run faster.
352 /// Default: 200 000.
353 pub steps_per_thread: usize,
354 /// **Larson only.** Number of live blocks each thread keeps in its working
355 /// set. Default: 512.
356 pub working_set: usize,
357 /// **Mstress only.** Number of blocks per mstress round.
358 /// Default: 256.
359 pub mstress_blocks: usize,
360}
361
362impl Default for Config {
363 fn default() -> Self {
364 let threads = std::thread::available_parallelism()
365 .map(|n| n.get())
366 .unwrap_or(4);
367 Self {
368 threads,
369 steps_per_thread: 200_000,
370 working_set: 512,
371 mstress_blocks: 256,
372 }
373 }
374}
375
376/// Run one workload × allocator × thread-count and return aggregate ops/sec.
377///
378/// `A` is your allocator — typically a ZST (`System`, `mimalloc::MiMalloc`,
379/// etc.). A fresh instance is constructed per thread via `make_alloc`.
380///
381/// The harness:
382/// 1. Spawns `config.threads` workers, each with its own mailbox channel for
383/// cross-thread block handoff.
384/// 2. Uses a [`Barrier`] so all workers start the timed region together
385/// (eliminates thread-spawn skew from the measurement).
386/// 3. Sums total ops across all workers and divides by wall-clock elapsed.
387///
388/// # Safety contract upheld by the harness
389///
390/// Every block allocated by `A::alloc` is freed exactly once by exactly one
391/// thread via `A::dealloc`. The harness manages ownership bookkeeping (slot
392/// `Option` discipline + mpsc transfer) so callers do not need to.
393///
394/// The harness itself calls `A::alloc` / `A::dealloc` (the `GlobalAlloc`
395/// trait is `unsafe`); those calls follow the safety contracts of
396/// [`GlobalAlloc`].
397///
398/// # Example
399///
400/// ```rust
401/// use malloc_bench_rs::{run, Config, Workload};
402/// use std::alloc::System;
403///
404/// let cfg = Config { threads: 1, steps_per_thread: 1_000, working_set: 64, mstress_blocks: 32 };
405/// let ops = run(Workload::Larson, &cfg, || System);
406/// assert!(ops > 0.0);
407/// ```
408pub fn run<A>(workload: Workload, config: &Config, make_alloc: fn() -> A) -> f64
409where
410 A: GlobalAlloc + Send + 'static,
411{
412 let threads = config.threads.max(1);
413 let steps = config.steps_per_thread;
414 let working_set = config.working_set.max(1);
415 let mstress_blocks = config.mstress_blocks.max(1);
416
417 // Per-thread cross-thread mailboxes.
418 let mut senders: Vec<Sender<Block>> = Vec::with_capacity(threads);
419 let mut receivers: Vec<Option<Receiver<Block>>> = Vec::with_capacity(threads);
420 for _ in 0..threads {
421 let (tx, rx) = channel::<Block>();
422 senders.push(tx);
423 receivers.push(Some(rx));
424 }
425 let senders = Arc::new(senders);
426
427 // Barrier: align all workers so the timed region is the steady state, not
428 // thread-spawn skew. +1 for the main thread that starts/stops the clock.
429 let barrier = Arc::new(Barrier::new(threads + 1));
430
431 let mut handles = Vec::with_capacity(threads);
432 for (t, rx_slot) in receivers.iter_mut().enumerate() {
433 let senders = Arc::clone(&senders);
434 let barrier = Arc::clone(&barrier);
435 let rx = rx_slot.take().unwrap();
436 // Per-thread seed derived from the thread index — fixed, reproducible.
437 let seed = 0x9E37_79B9_7F4A_7C15u64
438 .wrapping_mul(t as u64 + 1)
439 .wrapping_add(0xDEAD_BEEF);
440 let alloc = make_alloc();
441
442 let handle = thread::spawn(move || {
443 // Each worker waits at the barrier so all start together.
444 barrier.wait();
445
446 // SAFETY: `alloc` is a valid GlobalAlloc; workers uphold the
447 // free-exactly-once invariant (see module docs).
448 let ops = unsafe {
449 match workload {
450 Workload::Larson => {
451 larson_worker(&alloc, seed, steps, working_set, &senders, &rx, t)
452 }
453 Workload::Mstress => mstress_worker(
454 &alloc,
455 seed,
456 steps / mstress_blocks.max(1) + 1,
457 mstress_blocks,
458 &senders,
459 &rx,
460 t,
461 ),
462 }
463 };
464
465 // Final drain: free any cross-thread blocks that arrived after
466 // our loop ended so nothing is leaked.
467 let mut extra = 0u64;
468 // SAFETY: uniquely owned inbound blocks.
469 unsafe { drain_mailbox(&alloc, &rx, &mut extra) };
470 ops + extra
471 });
472 handles.push(handle);
473 }
474
475 // Start the clock once every worker is at the barrier (steady state).
476 barrier.wait();
477 let start = Instant::now();
478
479 let mut total_ops: u64 = 0;
480 for h in handles {
481 total_ops += h.join().expect("worker panicked");
482 }
483 let elapsed = start.elapsed();
484
485 // After all workers joined, every sender is dropped and every receiver was
486 // drained in the worker's final step: no block is leaked or double-freed.
487 drop(senders);
488
489 total_ops as f64 / elapsed.as_secs_f64()
490}
491
492/// Run a thread-count sweep for one workload × allocator.
493///
494/// For each `T` in `threads_sweep`, runs [`run`] with `config.threads = T`
495/// and returns a `Vec<(threads, ops_per_sec)>`.
496///
497/// This is the primary entry point for scalability tables: you call `sweep`
498/// once per allocator, zip the results, and print a comparison table.
499///
500/// # Example
501///
502/// ```rust
503/// use malloc_bench_rs::{sweep, Config, Workload};
504/// use std::alloc::System;
505///
506/// let cfg = Config { threads: 1, steps_per_thread: 1_000, working_set: 64, mstress_blocks: 32 };
507/// let results = sweep(Workload::Larson, &cfg, &[1, 2], || System);
508/// assert_eq!(results.len(), 2);
509/// assert!(results[0].1 > 0.0);
510/// ```
511pub fn sweep<A>(
512 workload: Workload,
513 config: &Config,
514 threads_sweep: &[usize],
515 make_alloc: fn() -> A,
516) -> Vec<(usize, f64)>
517where
518 A: GlobalAlloc + Send + 'static,
519{
520 threads_sweep
521 .iter()
522 .map(|&t| {
523 let mut cfg = config.clone();
524 cfg.threads = t;
525 let ops = run(workload, &cfg, make_alloc);
526 (t, ops)
527 })
528 .collect()
529}