Skip to main content

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}