mimalloc_pprof/lib.rs
1//! Rust global allocator support for the in-tree mimalloc build.
2//!
3//! ```no_run
4//! use mimalloc_pprof::{prof, MiMalloc};
5//! #[global_allocator] static ALLOCATOR: MiMalloc = MiMalloc;
6//! # fn main() -> std::io::Result<()> {
7//! prof::start(512 * 1024);
8//! prof::dump_file(std::path::Path::new("heap.prof"))?;
9//! # Ok(()) }
10//! ```
11//!
12//! Profiling is enabled by default. To build the allocator without profiler
13//! hooks, depend on this crate with `default-features = false`; in that mode the
14//! profiling API remains available but cannot start a profiler.
15//!
16//! See the README's Rust integration guide for frame-pointer and line-table
17//! build flags. Open the resulting profile with `pprof -http=: app.exe heap.prof`.
18
19use core::alloc::{GlobalAlloc, Layout};
20use core::ffi::c_void;
21use std::ffi::CString;
22use std::path::PathBuf;
23
24pub mod sys;
25
26/// A `#[global_allocator]` implementation backed by mimalloc.
27pub struct MiMalloc;
28
29unsafe impl GlobalAlloc for MiMalloc {
30 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
31 sys::mi_malloc_aligned(layout.size(), layout.align()).cast()
32 }
33
34 unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) {
35 sys::mi_free(ptr.cast::<c_void>());
36 }
37
38 unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
39 sys::mi_realloc_aligned(ptr.cast::<c_void>(), new_size, layout.align()).cast()
40 }
41
42 unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
43 sys::mi_zalloc_aligned(layout.size(), layout.align()).cast()
44 }
45}
46
47/// Allocate `size` bytes from mimalloc's raw-OS-layer "unwrapped" path.
48///
49/// Thin wrapper around `mi_unwrapped_malloc` (include/mimalloc/memory-events.h):
50/// backed directly by `_mi_os_alloc_aligned`, never by the hooked `mi_malloc`
51/// family. Page granular, so this is not meant for hot-path/small allocations
52/// — it exists for low-level instrumentation and recursion avoidance (e.g.
53/// scratch storage for a memory-change callback that must not recursively
54/// enter mimalloc). Excluded from normal mimalloc allocation stats and from
55/// the memory-change accounting.
56///
57/// Returns a null pointer on failure (including invalid `alignment`; see
58/// `# Safety` below).
59///
60/// # Safety
61///
62/// - `alignment` must be `0` (treated as `align_of::<*const ()>()`, i.e.
63/// pointer size) or a power of two. A non-power-of-two, non-zero alignment
64/// is a validated input on the C side: `mi_unwrapped_malloc` returns a null
65/// pointer rather than invoking undefined behavior, but callers should not
66/// rely on that as anything other than a defined-failure contract — treat
67/// the alignment argument as a precondition to get right, not a value to
68/// probe.
69/// - The returned pointer, if non-null, must be passed only to
70/// [`unwrapped_free`] or [`unwrapped_realloc`] — never to `mi_free`, this
71/// crate's [`MiMalloc`] allocator, or Rust's global allocator, and vice
72/// versa (a pointer from `mi_malloc`/the Rust global allocator must never
73/// be passed to [`unwrapped_free`]/[`unwrapped_realloc`]). Mixing these
74/// families corrupts allocator-internal bookkeeping.
75/// - The memory is uninitialized; reading it before writing is undefined
76/// behavior, as with any raw allocation.
77pub unsafe fn unwrapped_malloc(size: usize, alignment: usize) -> *mut u8 {
78 unsafe { sys::mi_unwrapped_malloc(size, alignment).cast() }
79}
80
81/// Free a pointer returned by [`unwrapped_malloc`] or [`unwrapped_realloc`].
82///
83/// Thin wrapper around `mi_unwrapped_free` (include/mimalloc/memory-events.h).
84///
85/// # Safety
86///
87/// - `p` must be either a null pointer (a documented, safe no-op on the C
88/// side) or a pointer previously returned by [`unwrapped_malloc`] or
89/// [`unwrapped_realloc`] that has not already been freed.
90/// - `p` must never have come from `mi_malloc`, this crate's [`MiMalloc`]
91/// allocator, or Rust's global allocator — passing such a pointer here is
92/// undefined behavior (the "unwrapped" and normal allocation families use
93/// incompatible header layouts and are validated by a magic-number check
94/// that a foreign pointer will not satisfy).
95pub unsafe fn unwrapped_free(p: *mut u8) {
96 unsafe { sys::mi_unwrapped_free(p.cast()) }
97}
98
99/// Resize a pointer returned by [`unwrapped_malloc`] or [`unwrapped_realloc`].
100///
101/// Thin wrapper around `mi_unwrapped_realloc` (include/mimalloc/memory-events.h).
102/// If `p` is null, this behaves like [`unwrapped_malloc`]. If `new_size` is
103/// `0`, this frees `p` (like [`unwrapped_free`]) and returns a null pointer.
104/// Otherwise the existing contents are copied into a freshly allocated
105/// unwrapped block (up to `min(old payload size, new_size)` bytes) and `p` is
106/// freed; `p` must not be used again after this call, whether or not it
107/// returns null.
108///
109/// Returns a null pointer on failure (including invalid `alignment`; see
110/// [`unwrapped_malloc`]'s `# Safety` section), in which case `p` is left
111/// valid and unfreed.
112///
113/// # Safety
114///
115/// - `p` must be either a null pointer or a pointer previously returned by
116/// [`unwrapped_malloc`] or [`unwrapped_realloc`] that has not already been
117/// freed, per the same family-isolation rule as [`unwrapped_free`].
118/// - `alignment` has the same power-of-two-or-zero contract as
119/// [`unwrapped_malloc`].
120/// - After this call, `p` must not be read, written, or freed again — treat
121/// it as consumed regardless of whether the return value is null.
122pub unsafe fn unwrapped_realloc(p: *mut u8, new_size: usize, alignment: usize) -> *mut u8 {
123 unsafe { sys::mi_unwrapped_realloc(p.cast(), new_size, alignment).cast() }
124}
125
126/// Grow or shrink an allocation, zeroing any newly-exposed tail.
127///
128/// Thin wrapper around `mi_rezalloc`. This is the operation Rust's [`GlobalAlloc`]
129/// cannot express — that trait has no `grow_zeroed` — so without it a caller has to
130/// grow and then `memset` by hand, repeating work the allocator has already done, and
131/// (with zero-tracking) work it may be able to skip entirely.
132///
133/// # What is actually zeroed
134///
135/// **Not** `[old_requested_size, new_size)`. mimalloc measures from the block's old
136/// *usable* size, so the slack between what you asked for and what the block actually
137/// holds is left untouched:
138///
139/// ```text
140/// requested 64 -> usable 80 -> rezalloc to 70
141/// bytes [64,70) are NOT zeroed: the grow was served in place, within the old block
142/// ```
143///
144/// The guarantee is: everything past [`usable_size`] of the *original* block is zero.
145/// If you need a specific range zeroed, capture [`usable_size`] before the call and
146/// zero the remainder yourself.
147///
148/// (This is documented so precisely because a fuzz harness asserted the intuitive
149/// version and was falsified within seconds — see issue #87.)
150///
151/// # Safety
152///
153/// - `p` must be null, or a pointer from the **plain** allocation family — the global
154/// allocator, [`sys::mi_malloc`], or a previous [`rezalloc`]/[`recalloc`] — that has
155/// not been freed.
156/// - **Not interchangeable with [`unwrapped_malloc`]/[`unwrapped_realloc`].** Those
157/// place a header before the pointer, so passing one here fails the pointer check
158/// (`mi_usable_size: invalid pointer`) rather than working by accident.
159/// - After this call `p` is consumed: do not read, write, or free it again, whether or
160/// not the return value is null.
161/// - On failure a null pointer is returned and `p` is left valid and unfreed.
162pub unsafe fn rezalloc(p: *mut u8, new_size: usize) -> *mut u8 {
163 unsafe { sys::mi_rezalloc(p.cast(), new_size).cast() }
164}
165
166/// Grow or shrink an allocation to `count * size` bytes, zeroing any newly-exposed tail.
167///
168/// The [`rezalloc`] contract applies, including what is and is not zeroed. Thin wrapper
169/// around `mi_recalloc`; the element-count form exists to mirror `calloc`.
170///
171/// # Safety
172///
173/// Same contract as [`rezalloc`].
174pub unsafe fn recalloc(p: *mut u8, count: usize, size: usize) -> *mut u8 {
175 unsafe { sys::mi_recalloc(p.cast(), count, size).cast() }
176}
177
178/// Try to grow an allocation **in place**, without moving it.
179///
180/// Returns a null pointer if the block cannot be extended where it is — in which case
181/// `p` remains valid and unchanged, unlike [`rezalloc`]. Useful when moving would be
182/// more expensive than falling back to a different strategy.
183///
184/// # Safety
185///
186/// - `p` must be a pointer from this allocator that has not been freed.
187/// - Unlike [`rezalloc`], `p` is **not** consumed: on failure it is still live and must
188/// still be freed.
189pub unsafe fn expand(p: *mut u8, new_size: usize) -> *mut u8 {
190 unsafe { sys::mi_expand(p.cast(), new_size).cast() }
191}
192
193/// Bytes actually available in an allocation, which may exceed what was requested.
194///
195/// # Safety
196///
197/// `p` must be a live pointer from this allocator.
198pub unsafe fn usable_size(p: *const u8) -> usize {
199 unsafe { sys::mi_usable_size(p.cast()) }
200}
201
202/// Live per-heap -> per-page -> (optional) per-block JSON snapshot of the current
203/// subprocess (issue #269, Bun parity P4). Backs Bun's shipped `bun:jsc`
204/// `heapStats({dump:true|"blocks"}).mimallocDump`; see `src/heap-dump.c` for the JSON
205/// shape (`{"heaps":[{"seq":N,"pages":[{"id","block_size","used","reserved","thread_id"}],
206/// "blocks":[[id,size],...]}]}`, `blocks` present only when `include_blocks`).
207///
208/// Set `hash_addresses` to mix every reported address through a per-process key so a
209/// dump can be shared or diffed without exposing raw ASLR-derived pointers.
210///
211/// Best-effort under concurrent frees on the heaps being walked (mimalloc's
212/// `mi_heap_visit_blocks`/`mi_subproc_visit_heaps` contract; see the caveat on
213/// `src/heap-dump.c` and issue #78) -- never `unsafe` to call, but a heap another
214/// thread is actively freeing into may be under- or over-reported in the returned JSON.
215///
216/// Returns `None` only on allocation failure (out of memory building the JSON buffer),
217/// not for an empty subprocess.
218pub fn heap_dump_json(include_blocks: bool, hash_addresses: bool) -> Option<String> {
219 use std::ffi::CStr;
220 let ptr = unsafe { sys::mi_heap_dump_json(include_blocks, hash_addresses) };
221 if ptr.is_null() {
222 return None;
223 }
224 let json = unsafe { CStr::from_ptr(ptr) }
225 .to_string_lossy()
226 .into_owned();
227 unsafe { sys::mi_free(ptr.cast()) };
228 Some(json)
229}
230
231/// Tell mimalloc this thread is idle (issue #272, Bun parity P7a).
232///
233/// Collects this thread's pending frees, discards the free blocks inside its still-used
234/// pages, and hands the arena purge to the background scavenger thread so freed memory
235/// returns to the OS now instead of at the next allocation that happens to run a purge --
236/// which, on a genuinely idle process, is never.
237///
238/// Safe on any thread; a no-op on a thread that never allocated. Call it when the thread
239/// has nothing to do (an event loop about to block, a worker pool waiting on its queue),
240/// not on a hot path: it costs a few `madvise`/`DiscardVirtualMemory` calls.
241pub fn on_thread_idle() {
242 unsafe { sys::mi_on_thread_idle() }
243}
244
245/// Guard form of [`on_thread_idle`] for a thread that is about to BLOCK: hands this
246/// thread's heaps to the background scavenger, which does the idle work above while this
247/// thread sits in the kernel, and takes them back on drop.
248///
249/// Returns `None` when nothing was handed off (no scavenger running, this thread never
250/// allocated, or it is already parked). That case is deliberately NOT an inline sweep: a
251/// caller blocks far more often than it is truly idle. If this park is idle enough to
252/// afford the work, call [`on_thread_idle`] instead.
253///
254/// The thread must not allocate or free between the call and the drop -- that is the
255/// precondition that lets another thread rewrite its free lists -- which is why the guard
256/// is `!Send` and holds no data.
257#[must_use = "the park ends when the guard is dropped"]
258pub fn park_while_idle() -> Option<IdlePark> {
259 if unsafe { sys::mi_on_thread_idle_start() } {
260 Some(IdlePark {
261 _not_send: core::marker::PhantomData,
262 })
263 } else {
264 None
265 }
266}
267
268/// Returned by [`park_while_idle`]; ends the park when dropped.
269pub struct IdlePark {
270 // the park is per-thread state: `mi_on_thread_idle_end` must run on the parking thread
271 _not_send: core::marker::PhantomData<*const ()>,
272}
273
274impl Drop for IdlePark {
275 fn drop(&mut self) {
276 unsafe { sys::mi_on_thread_idle_end() }
277 }
278}
279
280/// Stop the background scavenger thread (issue #272).
281///
282/// It restarts on demand (the next [`park_while_idle`], or the next thread that
283/// initializes), so this is a way to quiesce it -- e.g. before a `fork`/`exec` that counts
284/// threads, or in a test -- not a way to disable it permanently. For that, set the
285/// `scavenger` option to 0 (`MIMALLOC_SCAVENGER=0`) before the first allocation.
286pub fn scavenger_stop() {
287 unsafe { sys::mi_scavenger_stop() }
288}
289
290/// What page hole purging has reclaimed, process wide (issue #272, Bun parity P7b).
291///
292/// Hole purging discards the memory of the free blocks sitting inside pages that are still
293/// in use, at each [`on_thread_idle`] / [`park_while_idle`] point -- without it a page stays
294/// fully resident until every block in it is free, so one long-lived object pins a whole
295/// 64 KiB/512 KiB page. These counters are the only way to see how much that gets back;
296/// they are deliberately not part of `mi_stats_t`, because the sweep also covers pages that
297/// no heap owns.
298///
299/// Most fields are monotonic. `purged_bytes`, `purged_blocks` and `unformed_bytes` are
300/// gauges ("right now"), and the three `ineligible_*` fields are a gauge over the LAST sweep
301/// only. Everything is zero when the `purge_holes` option is off (`MIMALLOC_PURGE_HOLES=0`).
302///
303/// ```
304/// # use mimalloc_pprof as mi;
305/// let before = mi::purge_holes_stats().purged_bytes_total;
306/// mi::on_thread_idle();
307/// let after = mi::purge_holes_stats().purged_bytes_total;
308/// assert!(after >= before);
309/// ```
310#[must_use]
311pub fn purge_holes_stats() -> sys::MiPurgeHolesStats {
312 let mut stats = sys::MiPurgeHolesStats::default();
313 unsafe { sys::mi_purge_holes_stats_get(&raw mut stats) };
314 stats
315}
316
317/// Exact DHAT v2 heap/lifetime profiling controls.
318///
319/// DHAT records every non-internal allocation from the moment [`start`] succeeds.
320/// It is intended for short diagnostic runs and tests rather than continuous production
321/// telemetry. The generated JSON opens in the standard Valgrind `dh_view.html` viewer.
322/// It is independent of sampled [`prof`] profiling and of `mi_memory_set_callbacks`.
323pub mod dhat {
324 use std::ffi::CString;
325 use std::io;
326 use std::path::Path;
327
328 use crate::sys;
329
330 /// Snapshot of exact DHAT collector state.
331 #[derive(Debug, Clone, Default, PartialEq, Eq)]
332 pub struct Stats {
333 pub enabled: bool,
334 /// True when raw-OS collector storage hit its configured budget or an internal
335 /// allocation failed. The application allocation still completed, but the
336 /// resulting profile is intentionally marked partial.
337 pub incomplete: bool,
338 pub total_bytes: u64,
339 pub total_blocks: u64,
340 pub live_bytes: u64,
341 pub live_blocks: u64,
342 pub peak_bytes: u64,
343 pub peak_blocks: u64,
344 pub dropped: u64,
345 pub internal_bytes: u64,
346 }
347
348 /// Start exact allocation/lifetime tracking. Returns `false` if it is already active.
349 pub fn start() -> bool {
350 unsafe { sys::mi_dhat_start() }
351 }
352
353 /// Stop observing allocation events. Retained records remain available to [`dump_file`]
354 /// so a caller can stop a measurement window before serializing its report.
355 pub fn stop() {
356 unsafe { sys::mi_dhat_stop() }
357 }
358
359 /// Whether exact DHAT tracking is currently active.
360 pub fn is_enabled() -> bool {
361 unsafe { sys::mi_dhat_is_enabled() }
362 }
363
364 /// Read the collector's exact counters. Returns a zero/default snapshot only if the
365 /// linked C library rejected the versioned ABI structure.
366 pub fn stats() -> Stats {
367 let mut raw: sys::mi_dhat_stats_t = unsafe { core::mem::zeroed() };
368 raw.size = core::mem::size_of::<sys::mi_dhat_stats_t>();
369 raw.version = sys::MI_DHAT_STATS_VERSION;
370 if unsafe { sys::mi_dhat_stats_get(&mut raw) } {
371 Stats {
372 enabled: raw.enabled,
373 incomplete: raw.incomplete,
374 total_bytes: raw.total_bytes,
375 total_blocks: raw.total_blocks,
376 live_bytes: raw.live_bytes,
377 live_blocks: raw.live_blocks,
378 peak_bytes: raw.peak_bytes,
379 peak_blocks: raw.peak_blocks,
380 dropped: raw.dropped,
381 internal_bytes: raw.internal_bytes,
382 }
383 } else {
384 Stats::default()
385 }
386 }
387
388 /// Serialize the current or stopped measurement window as a DHAT v2 JSON file.
389 pub fn dump_file(path: &Path) -> io::Result<()> {
390 let path = path
391 .to_str()
392 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "DHAT path is not UTF-8"))?;
393 let path = CString::new(path)
394 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "DHAT path contains NUL"))?;
395 if unsafe { sys::mi_dhat_dump(path.as_ptr()) } {
396 Ok(())
397 } else {
398 Err(io::Error::last_os_error())
399 }
400 }
401}
402
403/// Turn on sampled heap profiling at the default sample rate.
404///
405/// Convenience entry point for wiring profiling to a command-line flag:
406///
407/// ```no_run
408/// # let args_profile_heap = true;
409/// if args_profile_heap {
410/// mimalloc_pprof::enable_heap_profiling();
411/// }
412/// ```
413///
414/// Uses the built-in default rate (one sample per ~512 KiB allocated;
415/// `MIMALLOC_PROF_SAMPLE_RATE` still overrides it). Call [`prof::start`]
416/// instead to pick a rate programmatically. Allocations made before this
417/// call — including process-startup and static initialization — are not
418/// tracked; profiles reflect steady-state behavior from this point on,
419/// which is the usual intent for an opt-in CLI switch. To capture startup
420/// as well, set `MIMALLOC_PROF=1` in the environment instead.
421///
422/// Returns `false` if profiling was already enabled (the earlier session,
423/// and its sample rate, stay active), or if the crate was built with
424/// `default-features = false`.
425pub fn enable_heap_profiling() -> bool {
426 prof::start(0)
427}
428
429/// How [`ProfConfig`] fields interact with the profiler's environment
430/// variables and `mi_option_*` settings.
431///
432/// Mirrors `mi_prof_config_mode_t` (include/mimalloc/profile.h); see that
433/// header for the full FALLBACK/OVERRIDE semantics, including the caveat
434/// that in `Override` mode `accum == false`, `dump_format == Text`, and
435/// `max_profiler_bytes == None` cannot be distinguished from "unset" and so
436/// always fall back to env-then-default rather than forcing the off/default
437/// value.
438#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
439pub enum ProfConfigMode {
440 /// Struct fields are used only where the corresponding env var / option is absent.
441 #[default]
442 Fallback,
443 /// Non-default struct fields win over env vars / options (see the caveat above).
444 Override,
445}
446
447/// Output format for [`ProfConfig::dump_at_exit`].
448///
449/// Mirrors `MI_PROF_FORMAT_TEXT` / `MI_PROF_FORMAT_PROTO` (include/mimalloc/profile.h).
450#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
451pub enum DumpFormat {
452 /// Legacy "heap profile:" text format (see [`prof::dump_to_vec`]).
453 #[default]
454 Text,
455 /// Binary pprof `profile.proto` format (see [`prof::dump_proto_to_vec`]).
456 Proto,
457}
458
459/// Ergonomic, Rust-facing sibling of `mi_prof_config_t`
460/// (include/mimalloc/profile.h) for [`enable_heap_profiling_with`].
461///
462/// Fields mirror the C struct one-for-one, but trade its 0/NULL-means-unset
463/// raw-integer conventions for `Option<T>` and enums where that reads
464/// better. `#[non_exhaustive]` + `Default` keeps future fields additive:
465/// build from `Default::default()` and set the fields you need, e.g.
466///
467/// ```
468/// use mimalloc_pprof::ProfConfig;
469/// let mut config = ProfConfig::default();
470/// config.sample_interval = Some(4096);
471/// ```
472///
473/// (Within this crate, struct-update syntax like
474/// `ProfConfig { sample_interval: Some(4096), ..Default::default() }` also
475/// works; `#[non_exhaustive]` only blocks struct-literal construction from
476/// *other* crates, so new fields stay non-breaking for them.)
477#[non_exhaustive]
478#[derive(Debug, Clone, Default)]
479pub struct ProfConfig {
480 /// See [`ProfConfigMode`].
481 pub mode: ProfConfigMode,
482 /// Average bytes between samples. `None` = env/default (512 KiB).
483 pub sample_interval: Option<usize>,
484 /// Budget (bytes) for profiler-internal persistent sampling state
485 /// (sample records, the stack intern table, interned stack entries).
486 /// `None` = unbudgeted (cap-bounded only).
487 pub max_profiler_bytes: Option<usize>,
488 /// `None` = nondeterministic.
489 pub seed: Option<u64>,
490 pub accum: bool,
491 /// `None` = default (32); compile cap 128.
492 pub max_stack_depth: Option<usize>,
493 /// Path to dump the profile to at process exit. `None` = no exit dump.
494 pub dump_at_exit: Option<PathBuf>,
495 /// Format used for the exit dump. Ignored if `dump_at_exit` is `None`.
496 pub dump_format: DumpFormat,
497}
498
499/// Turn on sampled heap profiling using a struct-based configuration.
500///
501/// Sibling of [`enable_heap_profiling`] for callers that need more than a
502/// single sample rate -- e.g. seeding the sampler, capping profiler-arena
503/// memory, or registering an exit-time dump path/format. See [`ProfConfig`]
504/// and, for the full FALLBACK/OVERRIDE semantics, `mi_prof_config_mode_t` in
505/// `include/mimalloc/profile.h`.
506///
507/// Returns `false` if profiling was already enabled (the earlier session
508/// stays active), if the crate was built with `default-features = false`, or
509/// if `config.dump_at_exit` is set but is not
510/// representable as a NUL-free C string (non-UTF-8 or an embedded NUL byte)
511/// -- in that case `mi_prof_start_ex` is never called.
512pub fn enable_heap_profiling_with(config: &ProfConfig) -> bool {
513 // `dump_at_exit_c` must outlive the `mi_prof_start_ex` call below since
514 // `raw.dump_at_exit` borrows its bytes; it does, as both live to the end
515 // of this function.
516 let dump_at_exit_c: Option<CString> = match &config.dump_at_exit {
517 Some(path) => match path.to_str().and_then(|s| CString::new(s).ok()) {
518 Some(c) => Some(c),
519 None => return false,
520 },
521 None => None,
522 };
523
524 let mut raw: sys::mi_prof_config_t = unsafe { core::mem::zeroed() };
525 raw.size = core::mem::size_of::<sys::mi_prof_config_t>();
526 raw.version = sys::MI_PROF_CONFIG_VERSION;
527 raw.mode = match config.mode {
528 ProfConfigMode::Fallback => sys::MI_PROF_CONFIG_FALLBACK,
529 ProfConfigMode::Override => sys::MI_PROF_CONFIG_OVERRIDE,
530 };
531 raw.sample_interval = config.sample_interval.unwrap_or(0);
532 raw.max_profiler_bytes = config.max_profiler_bytes.unwrap_or(0);
533 raw.seed = config.seed.unwrap_or(0);
534 raw.accum = config.accum;
535 raw.max_stack_depth = config.max_stack_depth.unwrap_or(0);
536 raw.dump_at_exit = dump_at_exit_c
537 .as_ref()
538 .map_or(core::ptr::null(), |c| c.as_ptr());
539 raw.dump_format = match config.dump_format {
540 DumpFormat::Text => sys::MI_PROF_FORMAT_TEXT,
541 DumpFormat::Proto => sys::MI_PROF_FORMAT_PROTO,
542 };
543
544 unsafe { sys::mi_prof_start_ex(&raw) }
545}
546
547/// Safe controls for mimalloc's sampled heap profiler.
548pub mod prof {
549 use core::ffi::{c_char, c_void};
550 use std::ffi::{CStr, CString};
551 use std::io;
552 use std::panic::{catch_unwind, AssertUnwindSafe};
553 use std::path::Path;
554
555 use crate::sys;
556
557 pub fn start(sample_rate: usize) -> bool {
558 unsafe { sys::mi_prof_start(sample_rate) }
559 }
560 #[doc(hidden)]
561 pub fn start_seeded(sample_rate: usize, seed: u64) -> bool {
562 unsafe { sys::mi_prof_start_seeded(sample_rate, seed) }
563 }
564 pub fn stop() {
565 unsafe { sys::mi_prof_stop() }
566 }
567 pub fn is_enabled() -> bool {
568 unsafe { sys::mi_prof_is_enabled() }
569 }
570 pub fn reset() {
571 unsafe { sys::mi_prof_reset() }
572 }
573
574 pub fn dump_file(path: &Path) -> io::Result<()> {
575 let path = path.to_str().ok_or_else(|| {
576 io::Error::new(io::ErrorKind::InvalidInput, "profile path is not UTF-8")
577 })?;
578 let path = CString::new(path).map_err(|_| {
579 io::Error::new(io::ErrorKind::InvalidInput, "profile path contains NUL")
580 })?;
581 if unsafe { sys::mi_prof_dump(path.as_ptr()) } {
582 Ok(())
583 } else {
584 Err(io::Error::last_os_error())
585 }
586 }
587
588 unsafe extern "C" fn write_cb(arg: *mut c_void, buf: *const c_char, len: usize) {
589 let out = &mut *(arg as *mut Vec<u8>);
590 out.extend_from_slice(core::slice::from_raw_parts(buf.cast::<u8>(), len));
591 }
592
593 /// Serialize the current heap profile without holding the profiler lock.
594 pub fn dump_to_vec() -> Vec<u8> {
595 let mut out = Vec::new();
596 let ok =
597 unsafe { sys::mi_prof_dump_writer(Some(write_cb), (&mut out as *mut Vec<u8>).cast()) };
598 if ok {
599 out
600 } else {
601 Vec::new()
602 }
603 }
604
605 /// Serialize the current heap profile as a binary pprof `profile.proto`
606 /// `Profile` message (see [google/pprof's `profile.proto`][proto]),
607 /// without holding the profiler lock.
608 ///
609 /// Sample values are pre-scaled the same way Go's `runtime/pprof` scales
610 /// legacy heap samples (the `protomem.go` convention: `alloc_objects`,
611 /// `alloc_space`, `inuse_objects`, `inuse_space`, each already corrected
612 /// for Poisson sampling bias rather than left for a downstream tool to
613 /// rescale). The `Mapping` table is included, so external symbolizers
614 /// need only the binary — no text parsing of a "heap profile:" header or
615 /// a `MAPPED_LIBRARIES:` section. This is the compact, machine-oriented
616 /// counterpart to [`dump_to_vec`]'s text format, intended for API and
617 /// transport use (issue #23) where a `pprof`-compatible tool consumes
618 /// the bytes directly.
619 ///
620 /// [proto]: https://github.com/google/pprof/blob/main/proto/profile.proto
621 pub fn dump_proto_to_vec() -> Vec<u8> {
622 let mut out = Vec::new();
623 let ok = unsafe {
624 sys::mi_prof_dump_proto_writer(Some(write_cb), (&mut out as *mut Vec<u8>).cast())
625 };
626 if ok {
627 out
628 } else {
629 Vec::new()
630 }
631 }
632
633 /// Write the current heap profile to `path` in `profile.proto` format.
634 ///
635 /// See [`dump_proto_to_vec`] for the format details.
636 pub fn dump_proto_file(path: &Path) -> io::Result<()> {
637 let path = path.to_str().ok_or_else(|| {
638 io::Error::new(io::ErrorKind::InvalidInput, "profile path is not UTF-8")
639 })?;
640 let path = CString::new(path).map_err(|_| {
641 io::Error::new(io::ErrorKind::InvalidInput, "profile path contains NUL")
642 })?;
643 if unsafe { sys::mi_prof_dump_proto(path.as_ptr()) } {
644 Ok(())
645 } else {
646 Err(io::Error::last_os_error())
647 }
648 }
649
650 /// Snapshot of `mi_prof_stats_get`'s counters, translated from the raw
651 /// sys struct into plain Rust types.
652 #[derive(Debug, Clone, Default)]
653 pub struct ProfStats {
654 pub enabled: bool,
655 pub accum: bool,
656 pub sample_rate: usize,
657 pub live_samples: usize,
658 pub live_bytes: usize,
659 pub accum_samples: usize,
660 pub accum_bytes: usize,
661 pub unique_stacks: usize,
662 pub arena_committed: usize,
663 pub stack_table_overflows: usize,
664 /// Count of ALL dropped samples (record-alloc failure, stack-intern
665 /// failure, including the stack-table cap); a superset of
666 /// `stack_table_overflows`, so `dropped_samples >=
667 /// stack_table_overflows` always.
668 pub dropped_samples: usize,
669 /// Allocator-level ("ground truth") counters, read from the mimalloc v3
670 /// engine's per-heap statistics at the time of the call. Every field
671 /// above is *sampled*; these are exact, so comparing them against
672 /// `live_bytes` measures the sampler's error directly -- which is what
673 /// makes an assertion on a sampled profile meaningful in a test.
674 pub heap: HeapStats,
675 }
676
677 /// Exact allocator counters accompanying a [`ProfStats`] reading.
678 ///
679 /// These come from mimalloc v3's per-heap statistics
680 /// (`mi_heap_stats_get`/`mi_subproc_stats_get`), which the v2 engine did not
681 /// expose. They are valid even when the profiler is stopped.
682 #[derive(Debug, Clone, Default)]
683 pub struct HeapStats {
684 /// Bytes currently committed from the OS.
685 pub committed: usize,
686 /// Bytes currently reserved from the OS (always `>= committed`).
687 pub reserved: usize,
688 /// Bytes the application actually requested and still holds.
689 ///
690 /// Only maintained when the C library was built with `MI_STAT >= 2`;
691 /// otherwise this is 0. Check [`HeapStats::detailed`] before using it.
692 pub malloc_requested: usize,
693 /// Live mimalloc pages.
694 pub pages: usize,
695 /// Pages abandoned by exited threads.
696 pub pages_abandoned: usize,
697 /// Live first-class heaps.
698 pub heaps: usize,
699 /// Live thread-local heaps. The main thread's statically-initialized
700 /// theap is not counted, so a single-threaded process reports 0.
701 pub theaps: usize,
702 /// Cumulative bytes purged back to the OS.
703 pub purged: usize,
704 /// Whether the C library was built with `MI_STAT >= 2` ("detailed"
705 /// statistics), which upstream enables by default only for debug
706 /// builds. [`HeapStats::malloc_requested`] is maintained only at that
707 /// level; every other field here is maintained at any level.
708 ///
709 /// Without this flag you cannot tell "the application allocated
710 /// nothing" from "this build does not track that counter".
711 pub detailed: bool,
712 }
713
714 /// Read the profiler's current counters via `mi_prof_stats_get`.
715 ///
716 /// Returns `ProfStats::default()` (all zero/false) if the call fails,
717 /// e.g. because the sys struct's `size`/`version` header does not match
718 /// what the linked mimalloc build expects.
719 pub fn stats() -> ProfStats {
720 let mut raw: sys::mi_prof_stats_t = unsafe { core::mem::zeroed() };
721 raw.size = core::mem::size_of::<sys::mi_prof_stats_t>();
722 raw.version = sys::MI_PROF_STAT_VERSION;
723 if unsafe { sys::mi_prof_stats_get(&mut raw) } {
724 ProfStats {
725 enabled: raw.enabled,
726 accum: raw.accum,
727 sample_rate: raw.sample_rate,
728 live_samples: raw.live_samples,
729 live_bytes: raw.live_bytes,
730 accum_samples: raw.accum_samples,
731 accum_bytes: raw.accum_bytes,
732 unique_stacks: raw.unique_stacks,
733 arena_committed: raw.arena_committed,
734 stack_table_overflows: raw.stack_table_overflows,
735 dropped_samples: raw.dropped_samples,
736 heap: HeapStats {
737 committed: raw.heap_committed,
738 reserved: raw.heap_reserved,
739 malloc_requested: raw.heap_malloc_requested,
740 pages: raw.heap_pages,
741 pages_abandoned: raw.heap_pages_abandoned,
742 heaps: raw.heap_count,
743 theaps: raw.theap_count,
744 purged: raw.heap_purged,
745 detailed: raw.heap_stats_detailed,
746 },
747 }
748 } else {
749 ProfStats::default()
750 }
751 }
752
753 /// One sampled call stack, copied out of the profiler by [`samples`].
754 #[derive(Debug, Clone)]
755 pub struct Sample {
756 pub stack: Vec<usize>,
757 pub live_objects: usize,
758 pub live_bytes: usize,
759 pub accum_objects: usize,
760 pub accum_bytes: usize,
761 }
762
763 impl Sample {
764 /// Estimate the un-sampled byte volume behind this sample.
765 ///
766 /// Mirrors pprof's legacy heap-sample scaling formula
767 /// (`scaleHeapSample` in pprof's `profile/legacy_profile.go`),
768 /// which corrects for the bias a Poisson sampling process with mean
769 /// interval `sample_rate` introduces toward larger allocations.
770 pub fn estimated_bytes(&self, sample_rate: usize) -> u64 {
771 if self.live_objects == 0 || self.live_bytes == 0 {
772 return 0;
773 }
774 if sample_rate <= 1 {
775 return self.live_bytes as u64;
776 }
777 let avg = self.live_bytes as f64 / self.live_objects as f64;
778 let scale = 1.0 / (1.0 - (-avg / sample_rate as f64).exp());
779 (self.live_bytes as f64 * scale) as u64
780 }
781 }
782
783 /// Frees the snapshot handle on drop, including on unwind, so a panic
784 /// partway through collection never leaks profiler-arena memory.
785 struct SnapshotGuard(*mut sys::mi_prof_snapshot_t);
786
787 impl Drop for SnapshotGuard {
788 fn drop(&mut self) {
789 unsafe { sys::mi_prof_snapshot_free(self.0) }
790 }
791 }
792
793 unsafe extern "C" fn collect_visitor(
794 info: *const sys::mi_prof_sample_info_t,
795 arg: *mut c_void,
796 ) -> bool {
797 let result = catch_unwind(AssertUnwindSafe(|| unsafe {
798 let out = &mut *(arg as *mut Vec<Sample>);
799 let info = &*info;
800 let stack = (0..info.depth)
801 .map(|i| *info.stack.add(i) as usize)
802 .collect();
803 out.push(Sample {
804 stack,
805 live_objects: info.live_objects,
806 live_bytes: info.live_bytes,
807 accum_objects: info.accum_objects,
808 accum_bytes: info.accum_bytes,
809 });
810 }));
811 result.is_ok()
812 }
813
814 /// Collect a point-in-time copy of every live sampled stack.
815 ///
816 /// This snapshots under the profiler lock via `mi_prof_snapshot_new`,
817 /// then walks and frees the snapshot outside that lock. Using
818 /// `mi_prof_visit` directly here would run the (allocating) collection
819 /// below from inside the visitor while the profiler lock is held,
820 /// risking reentrant profiler-hook allocation and deadlock — the
821 /// reentrancy hazard the snapshot API exists to avoid (issue #2,
822 /// decisions 11-13).
823 pub fn samples() -> Vec<Sample> {
824 let snap = unsafe { sys::mi_prof_snapshot_new() };
825 if snap.is_null() {
826 return Vec::new();
827 }
828 let guard = SnapshotGuard(snap);
829 let mut out: Vec<Sample> = Vec::new();
830 unsafe {
831 sys::mi_prof_snapshot_visit(
832 guard.0,
833 collect_visitor,
834 (&mut out as *mut Vec<Sample>).cast(),
835 );
836 }
837 out
838 }
839
840 /// One loaded module (shared library or the main executable), copied out
841 /// of the OS module list by [`modules`].
842 #[derive(Debug, Clone)]
843 pub struct ModuleInfo {
844 pub path: String,
845 pub base: usize,
846 pub size: usize,
847 }
848
849 unsafe extern "C" fn modules_visitor(
850 info: *const sys::mi_prof_module_info_t,
851 arg: *mut c_void,
852 ) -> bool {
853 let result = catch_unwind(AssertUnwindSafe(|| unsafe {
854 let out = &mut *(arg as *mut Vec<ModuleInfo>);
855 let info = &*info;
856 // `info.path` is only valid for the duration of this callback (it
857 // points into OS-owned module-list storage), so it must be copied
858 // into an owned `String` right here rather than stashed for later.
859 let path = CStr::from_ptr(info.path).to_string_lossy().into_owned();
860 out.push(ModuleInfo {
861 path,
862 base: info.base,
863 size: info.size,
864 });
865 }));
866 result.is_ok()
867 }
868
869 /// Enumerate the process's loaded modules (shared libraries and the main
870 /// executable), e.g. to build pprof `Mapping` entries yourself.
871 ///
872 /// Unlike [`samples`]'s `collect_visitor`, this callback is free to
873 /// allocate: `mi_prof_modules_visit` never takes the profiler lock (the
874 /// module list is OS-owned, not part of the sampled-allocation table), so
875 /// there is no reentrant-allocation-under-the-lock hazard here.
876 pub fn modules() -> Vec<ModuleInfo> {
877 let mut out: Vec<ModuleInfo> = Vec::new();
878 unsafe {
879 sys::mi_prof_modules_visit(modules_visitor, (&mut out as *mut Vec<ModuleInfo>).cast());
880 }
881 out
882 }
883}
884
885/// Print, per size class, what hole purging leaves behind (issue #272, Bun parity P7b).
886///
887/// Read-only: it purges nothing and mutates no free list. The report goes to mimalloc's
888/// own output sink (stderr by default), not to a returned `String` — building a `String`
889/// here would allocate from inside a walk over the very free lists being reported.
890/// `mi_purge_holes_report` takes no sink argument at all; to capture the text, install a
891/// process-wide sink with C's `mi_register_output` (not bound by this crate).
892///
893/// Like the idle sweep it only covers what the calling thread may safely read: its own
894/// theaps, plus the abandoned pages of the heaps behind them. Call it right after an
895/// [`on_thread_idle`] sweep, when the numbers still describe that sweep.
896pub fn purge_holes_report() {
897 unsafe { sys::mi_purge_holes_report() }
898}
899
900/// mimalloc's `mi_option_*` settings: the runtime knobs behind every `MIMALLOC_*`
901/// environment variable.
902///
903/// Options are read once, lazily, the first time the allocator needs them, so setting one
904/// after the allocation it governs has already happened has no effect. In particular
905/// [`Opt::SCAVENGER`] and the profiler options must be set before the first allocation to
906/// matter; [`Opt::PURGE_HOLES`] and its companions are re-read per sweep and can be
907/// changed at any time.
908///
909/// ```
910/// use mimalloc_pprof::options::{self, Opt};
911/// let previous = options::get(Opt::PURGE_HOLES_MIN_INTERVAL);
912/// options::set(Opt::PURGE_HOLES_MIN_INTERVAL, 0); // sweep on every idle call
913/// mimalloc_pprof::on_thread_idle();
914/// options::set(Opt::PURGE_HOLES_MIN_INTERVAL, previous);
915/// ```
916pub mod options {
917 use core::ffi::c_long;
918
919 use crate::sys;
920
921 /// One `mi_option_t` setting.
922 ///
923 /// The associated constants name this fork's own options plus the handful of upstream
924 /// ones that interact with them; [`Opt::from_raw`] reaches any other enumerator in
925 /// [`sys`] — it range-checks against `_mi_option_last`, because the C side indexes an
926 /// array with this value and an out-of-range option would read out of bounds.
927 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
928 pub struct Opt(sys::mi_option_t);
929
930 impl Opt {
931 /// **Fork addition.** Enable the sampled profiler at process start (`MIMALLOC_PROF`).
932 pub const PROF: Self = Self(sys::mi_option_prof);
933 /// **Fork addition.** Average byte interval between profiler samples.
934 pub const PROF_SAMPLE_RATE: Self = Self(sys::mi_option_prof_sample_rate);
935 /// **Fork addition.** Maximum captured stack depth for the profiler.
936 pub const PROF_BT_MAX: Self = Self(sys::mi_option_prof_bt_max);
937 /// **Fork addition.** Keep cumulative profiler counters until [`crate::prof::reset`].
938 pub const PROF_ACCUM: Self = Self(sys::mi_option_prof_accum);
939 /// **Fork addition.** Profiler sampling PRNG seed; 0 = nondeterministic.
940 pub const PROF_SEED: Self = Self(sys::mi_option_prof_seed);
941 /// **Fork addition.** Budget in bytes for profiler-internal arena memory.
942 pub const PROF_MAX_BYTES: Self = Self(sys::mi_option_prof_max_bytes);
943 /// **Fork addition.** Enable [`crate::memory_events`] accounting
944 /// (`MIMALLOC_MEMORY_EVENTS`).
945 pub const MEMORY_EVENTS: Self = Self(sys::mi_option_memory_events);
946 /// **Fork addition, dead since #80.** Parses, but has no effect. Kept so nothing
947 /// renumbers; unrelated to [`Opt::PURGE_HOLES_EAGER_ZERO`].
948 pub const PURGE_ZEROES: Self = Self(sys::mi_option_purge_zeroes);
949 /// **Fork addition (Bun).** Run the background scavenger thread.
950 pub const SCAVENGER: Self = Self(sys::mi_option_scavenger);
951 /// **Fork addition (Bun).** Discard free blocks inside still-used pages on
952 /// [`crate::on_thread_idle`].
953 pub const PURGE_HOLES: Self = Self(sys::mi_option_purge_holes);
954 /// **Fork addition (Bun).** Zero a range before discarding it, so a mis-scoped
955 /// discard corrupts visibly. Forced on in debug builds.
956 pub const PURGE_HOLES_EAGER_ZERO: Self = Self(sys::mi_option_purge_holes_eager_zero);
957 /// **Fork addition (Bun).** Minimum milliseconds between sweeps of one thread's heaps.
958 pub const PURGE_HOLES_MIN_INTERVAL: Self = Self(sys::mi_option_purge_holes_min_interval);
959 /// **Fork addition (Bun).** Every N-th sweep walks every page; 0 disables.
960 pub const PURGE_HOLES_FULL_EVERY: Self = Self(sys::mi_option_purge_holes_full_every);
961
962 /// Upstream: milliseconds to delay purging, which the scavenger also honours.
963 pub const PURGE_DELAY: Self = Self(sys::mi_option_purge_delay);
964 /// Upstream: print statistics on process termination.
965 pub const SHOW_STATS: Self = Self(sys::mi_option_show_stats);
966 /// Upstream: print error messages.
967 pub const SHOW_ERRORS: Self = Self(sys::mi_option_show_errors);
968 /// Upstream: print verbose messages.
969 pub const VERBOSE: Self = Self(sys::mi_option_verbose);
970
971 /// Wrap a raw `mi_option_t` from [`sys`], or `None` if it is not a real option.
972 ///
973 /// The range check is load bearing: the C implementation indexes its option table
974 /// with this value, so an out-of-range option is an out-of-bounds read.
975 #[must_use]
976 pub fn from_raw(raw: sys::mi_option_t) -> Option<Self> {
977 if (0..sys::_mi_option_last).contains(&raw) {
978 Some(Self(raw))
979 } else {
980 None
981 }
982 }
983
984 /// The raw `mi_option_t` value.
985 #[must_use]
986 pub fn as_raw(self) -> sys::mi_option_t {
987 self.0
988 }
989
990 /// The C enumerator's name, e.g. `mi_option_purge_holes`.
991 #[must_use]
992 pub fn name(self) -> &'static str {
993 sys::MI_OPTIONS_IN_ORDER
994 .get(self.0 as usize)
995 .map_or("<unknown>", |(name, _)| *name)
996 }
997 }
998
999 /// Read an option's value.
1000 ///
1001 /// Note the width: mimalloc stores option values in a C `long`, which is 32-bit on
1002 /// Windows and 64-bit on Linux/macOS. Use [`get_size`] for byte counts.
1003 #[must_use]
1004 pub fn get(option: Opt) -> c_long {
1005 unsafe { sys::mi_option_get(option.as_raw()) }
1006 }
1007
1008 /// Read an option's value, clamped into `min..=max`.
1009 #[must_use]
1010 pub fn get_clamp(option: Opt, min: c_long, max: c_long) -> c_long {
1011 unsafe { sys::mi_option_get_clamp(option.as_raw(), min, max) }
1012 }
1013
1014 /// Read an option's value as a `size_t`, for options that count bytes.
1015 #[must_use]
1016 pub fn get_size(option: Opt) -> usize {
1017 unsafe { sys::mi_option_get_size(option.as_raw()) }
1018 }
1019
1020 /// Set an option's value, overriding both the default and the environment.
1021 pub fn set(option: Opt, value: c_long) {
1022 unsafe { sys::mi_option_set(option.as_raw(), value) }
1023 }
1024
1025 /// Set an option's value only if the environment did not already set it.
1026 pub fn set_default(option: Opt, value: c_long) {
1027 unsafe { sys::mi_option_set_default(option.as_raw(), value) }
1028 }
1029
1030 /// Whether a boolean option is on.
1031 #[must_use]
1032 pub fn is_enabled(option: Opt) -> bool {
1033 unsafe { sys::mi_option_is_enabled(option.as_raw()) }
1034 }
1035
1036 /// Turn a boolean option on.
1037 pub fn enable(option: Opt) {
1038 unsafe { sys::mi_option_enable(option.as_raw()) }
1039 }
1040
1041 /// Turn a boolean option off.
1042 pub fn disable(option: Opt) {
1043 unsafe { sys::mi_option_disable(option.as_raw()) }
1044 }
1045
1046 /// Turn a boolean option on or off.
1047 pub fn set_enabled(option: Opt, enabled: bool) {
1048 unsafe { sys::mi_option_set_enabled(option.as_raw(), enabled) }
1049 }
1050
1051 /// Set a boolean option's default, which the environment still overrides.
1052 pub fn set_enabled_default(option: Opt, enabled: bool) {
1053 unsafe { sys::mi_option_set_enabled_default(option.as_raw(), enabled) }
1054 }
1055
1056 /// Print every option's current value to mimalloc's output sink.
1057 ///
1058 /// Goes to the sink rather than to a returned `String` for the same reason as
1059 /// [`crate::purge_holes_report`]: capturing it would mean allocating from inside a
1060 /// callback the allocator drives.
1061 pub fn print() {
1062 unsafe { sys::mi_options_print_out(None, core::ptr::null_mut()) }
1063 }
1064
1065 /// Every option this build knows about, in C declaration order, as
1066 /// `(name, value)` pairs — including the ones without an [`Opt`] constant.
1067 #[must_use]
1068 pub fn all() -> &'static [(&'static str, sys::mi_option_t)] {
1069 sys::MI_OPTIONS_IN_ORDER
1070 }
1071}
1072
1073/// The allocator's own **exact** statistics, as opposed to the sampled numbers
1074/// [`crate::prof::stats`] reports.
1075///
1076/// This is upstream mimalloc's `mimalloc-stats.h` surface. Note what is *not* here:
1077/// hole-purging and idle-sweep gauges are **not** part of `mi_stats_t` — they live in
1078/// [`crate::purge_holes_stats`], because the sweep also covers pages that no heap owns
1079/// and `mi_stats_t` cannot grow (it is embedded in a theap, at the meta-allocator's 8 KB
1080/// block limit).
1081///
1082/// `malloc_requested` is only maintained when the C library was built with `MI_STAT >= 2`
1083/// (upstream enables that for debug builds only); a default release build reports 0 for
1084/// it and for nothing else.
1085pub mod stats {
1086 use core::ops::Deref;
1087 use std::ffi::CStr;
1088
1089 use crate::sys;
1090
1091 /// An owned copy of `mi_stats_t`, boxed because it is ~4 KB.
1092 ///
1093 /// Deref to reach every counter, e.g. `stats.committed.current`.
1094 #[derive(Clone, Debug)]
1095 pub struct Stats(Box<sys::mi_stats_t>);
1096
1097 impl Deref for Stats {
1098 type Target = sys::mi_stats_t;
1099 fn deref(&self) -> &Self::Target {
1100 &self.0
1101 }
1102 }
1103
1104 impl Stats {
1105 /// Render these counters as mimalloc's statistics JSON.
1106 ///
1107 /// Returns `None` on allocation failure. Wraps `mi_stats_as_json`.
1108 #[must_use]
1109 pub fn to_json(&self) -> Option<String> {
1110 // `mi_stats_as_json` takes a non-const pointer but only reads through it.
1111 let mut copy = self.0.clone();
1112 let ptr = unsafe { sys::mi_stats_as_json(&raw mut *copy, 0, core::ptr::null_mut()) };
1113 take_c_string(ptr)
1114 }
1115
1116 /// The raw C struct.
1117 #[must_use]
1118 pub fn as_raw(&self) -> &sys::mi_stats_t {
1119 &self.0
1120 }
1121 }
1122
1123 /// A zeroed `mi_stats_t` with its `size`/`version` header filled in, which every
1124 /// `*_stats_get` entry point checks before writing a single counter.
1125 fn empty() -> Box<sys::mi_stats_t> {
1126 let mut raw: Box<sys::mi_stats_t> = Box::new(unsafe { core::mem::zeroed() });
1127 raw.size = size_of::<sys::mi_stats_t>();
1128 raw.version = sys::MI_STAT_VERSION;
1129 raw
1130 }
1131
1132 /// Copy a `mi_malloc`-family C string out and release it with `mi_free`.
1133 fn take_c_string(ptr: *mut core::ffi::c_char) -> Option<String> {
1134 if ptr.is_null() {
1135 return None;
1136 }
1137 let owned = unsafe { CStr::from_ptr(ptr) }
1138 .to_string_lossy()
1139 .into_owned();
1140 unsafe { sys::mi_free(ptr.cast()) };
1141 Some(owned)
1142 }
1143
1144 /// Which subprocess a subprocess-scoped call refers to.
1145 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1146 pub enum Subproc {
1147 /// The process-wide default subprocess (`mi_subproc_main`).
1148 #[default]
1149 Main,
1150 /// The subprocess this thread belongs to (`mi_subproc_current`).
1151 Current,
1152 }
1153
1154 impl Subproc {
1155 fn id(self) -> sys::mi_subproc_id_t {
1156 unsafe {
1157 match self {
1158 Self::Main => sys::mi_subproc_main(),
1159 Self::Current => sys::mi_subproc_current(),
1160 }
1161 }
1162 }
1163 }
1164
1165 /// Statistics for the current subprocess and all its heaps, aggregated.
1166 ///
1167 /// Wraps `mi_stats_get`. Returns `None` only if the C library rejects the struct
1168 /// header, which would mean this crate's `mi_stats_t` mirror has drifted from the
1169 /// library it is linked against (`tests/t19_layout.rs` gates exactly that).
1170 #[must_use]
1171 pub fn get() -> Option<Stats> {
1172 let mut raw = empty();
1173 unsafe { sys::mi_stats_get(&raw mut *raw) }.then_some(Stats(raw))
1174 }
1175
1176 /// The same statistics as [`get`], rendered as JSON by the C library.
1177 ///
1178 /// Wraps `mi_stats_get_json`; returns `None` on allocation failure.
1179 #[must_use]
1180 pub fn json() -> Option<String> {
1181 take_c_string(unsafe { sys::mi_stats_get_json(0, core::ptr::null_mut()) })
1182 }
1183
1184 /// Print the current subprocess's statistics to mimalloc's output sink.
1185 ///
1186 /// Wraps `mi_stats_print_out(NULL, NULL)`. Use [`json`] to capture them instead:
1187 /// routing the sink through a Rust closure would allocate from inside a callback the
1188 /// allocator drives.
1189 pub fn print() {
1190 unsafe { sys::mi_stats_print_out(None, core::ptr::null_mut()) }
1191 }
1192
1193 /// The block size served by size bin `bin` (`0..=`[`sys::MI_BIN_HUGE`]), matching the
1194 /// `malloc_bins`/`page_bins` indices.
1195 #[must_use]
1196 pub fn bin_size(bin: usize) -> usize {
1197 unsafe { sys::mi_stats_get_bin_size(bin) }
1198 }
1199
1200 /// Statistics for one subprocess and all its heaps, aggregated.
1201 #[must_use]
1202 pub fn subproc_get(which: Subproc) -> Option<Stats> {
1203 let mut raw = empty();
1204 unsafe { sys::mi_subproc_stats_get(which.id(), &raw mut *raw) }.then_some(Stats(raw))
1205 }
1206
1207 /// Statistics for one subprocess **without** aggregating its heaps.
1208 #[must_use]
1209 pub fn subproc_get_exclusive(which: Subproc) -> Option<Stats> {
1210 let mut raw = empty();
1211 unsafe { sys::mi_subproc_stats_get_exclusive(which.id(), &raw mut *raw) }
1212 .then_some(Stats(raw))
1213 }
1214
1215 /// One subprocess's aggregated statistics as JSON.
1216 #[must_use]
1217 pub fn subproc_json(which: Subproc) -> Option<String> {
1218 take_c_string(unsafe {
1219 sys::mi_subproc_stats_get_json(which.id(), 0, core::ptr::null_mut())
1220 })
1221 }
1222
1223 /// Print one subprocess's aggregated statistics to mimalloc's output sink.
1224 pub fn subproc_print(which: Subproc) {
1225 unsafe { sys::mi_subproc_stats_print_out(which.id(), None, core::ptr::null_mut()) }
1226 }
1227
1228 /// Print one subprocess **and each of its heaps separately** to mimalloc's output sink.
1229 pub fn subproc_heap_print(which: Subproc) {
1230 unsafe {
1231 sys::mi_subproc_heap_stats_print_out(which.id(), None, core::ptr::null_mut());
1232 }
1233 }
1234}
1235
1236/// Opt-in allocation-change accounting and callbacks (`include/mimalloc/memory-events.h`).
1237///
1238/// Independent of the sampled profiler: this module is compiled into the C library in
1239/// every configuration, including `default-features = false`. Tracking is **off** by
1240/// default; while it is off every allocate/free/realloc pays for exactly one relaxed flag
1241/// check and nothing else.
1242///
1243/// ```
1244/// use mimalloc_pprof::{memory_events, MiMalloc};
1245///
1246/// // The counters only move for allocations that actually reach mimalloc, so this
1247/// // example is only meaningful once mimalloc is the global allocator.
1248/// #[global_allocator]
1249/// static ALLOCATOR: MiMalloc = MiMalloc;
1250///
1251/// fn main() {
1252/// memory_events::set_enabled(true);
1253/// let before = memory_events::snapshot().expect("snapshot").accum_count;
1254/// let v = vec![0_u8; 4096];
1255/// std::hint::black_box(&v);
1256/// let after = memory_events::snapshot().expect("snapshot").accum_count;
1257/// assert!(after > before);
1258/// memory_events::set_enabled(false);
1259/// }
1260/// ```
1261pub mod memory_events {
1262 use core::ffi::c_void;
1263 use std::panic::{catch_unwind, AssertUnwindSafe};
1264
1265 use crate::sys;
1266
1267 /// Which kind of change a [`Change`] describes.
1268 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1269 #[non_exhaustive]
1270 pub enum ChangeKind {
1271 /// A successful allocation.
1272 Allocate,
1273 /// A successful free.
1274 Free,
1275 /// A successful realloc, whether it grew or shrank.
1276 Resize,
1277 }
1278
1279 impl ChangeKind {
1280 fn from_raw(raw: sys::mi_memory_change_kind_t) -> Option<Self> {
1281 match raw {
1282 sys::MI_MEMORY_ALLOCATE => Some(Self::Allocate),
1283 sys::MI_MEMORY_FREE => Some(Self::Free),
1284 sys::MI_MEMORY_RESIZE => Some(Self::Resize),
1285 _ => None,
1286 }
1287 }
1288
1289 fn slot(self) -> usize {
1290 match self {
1291 Self::Allocate => sys::MI_MEMORY_ALLOCATE as usize,
1292 Self::Free => sys::MI_MEMORY_FREE as usize,
1293 Self::Resize => sys::MI_MEMORY_RESIZE as usize,
1294 }
1295 }
1296 }
1297
1298 /// One observed allocation change, copied out of `mi_memory_change_t`.
1299 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1300 pub struct Change {
1301 /// What happened.
1302 pub kind: ChangeKind,
1303 /// Tracked global live usable bytes after this operation.
1304 pub total_bytes: u64,
1305 /// Signed change in tracked live usable bytes: positive for allocation/growth,
1306 /// negative for free/shrink, zero for a same-size-class resize.
1307 pub delta_bytes: i64,
1308 /// Caller-requested size for allocate and resize; zero for free.
1309 pub request_size: u64,
1310 }
1311
1312 /// Running totals maintained while tracking is enabled.
1313 ///
1314 /// Counters are **not** reconstructed for time spent with tracking off: a total is
1315 /// exact only if tracking was enabled before the first allocation and never disabled.
1316 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1317 pub struct Snapshot {
1318 /// Tracked live usable bytes right now.
1319 pub live_bytes: u64,
1320 /// Cumulative usable bytes ever allocated.
1321 pub accum_bytes: u64,
1322 /// Tracked live allocation count right now.
1323 pub live_count: u64,
1324 /// Cumulative count of successful allocate events.
1325 pub accum_count: u64,
1326 }
1327
1328 /// Enable or disable tracking; returns the previous state.
1329 ///
1330 /// An explicit call is always authoritative over the `MIMALLOC_MEMORY_EVENTS`
1331 /// environment read: called before the first allocation it *replaces* that read;
1332 /// called after, it overrides the cached flag. Re-enabling does not reconstruct what
1333 /// happened while tracking was off.
1334 pub fn set_enabled(enabled: bool) -> bool {
1335 unsafe { sys::mi_memory_tracking_set_enabled(enabled) }
1336 }
1337
1338 /// Whether tracking is on.
1339 #[must_use]
1340 pub fn is_enabled() -> bool {
1341 unsafe { sys::mi_memory_tracking_is_enabled() }
1342 }
1343
1344 /// Read the running totals.
1345 ///
1346 /// Returns `None` only if the C library rejects the struct header, which would mean
1347 /// this crate's mirror has drifted from the library (`tests/t19_layout.rs` gates that).
1348 #[must_use]
1349 pub fn snapshot() -> Option<Snapshot> {
1350 let mut raw: sys::mi_memory_snapshot_t = unsafe { core::mem::zeroed() };
1351 raw.size = size_of::<sys::mi_memory_snapshot_t>();
1352 raw.version = sys::MI_MEMORY_SNAPSHOT_VERSION;
1353 if !unsafe { sys::mi_memory_snapshot(&raw mut raw) } {
1354 return None;
1355 }
1356 Some(Snapshot {
1357 live_bytes: raw.live_bytes,
1358 accum_bytes: raw.accum_bytes,
1359 live_count: raw.live_count,
1360 accum_count: raw.accum_count,
1361 })
1362 }
1363
1364 /// Handlers to install with [`set_callbacks`], one per [`ChangeKind`].
1365 ///
1366 /// Plain `fn` pointers rather than closures on purpose: the C side keeps the
1367 /// registration until it is replaced, so anything captured would have to outlive the
1368 /// process. Route per-instance state through a `static` (an atomic counter, a channel
1369 /// sender in a `OnceLock`) instead.
1370 #[derive(Clone, Copy, Debug, Default)]
1371 pub struct Callbacks {
1372 /// Called after each successful allocation.
1373 pub allocate: Option<fn(&Change)>,
1374 /// Called after each successful free.
1375 pub free: Option<fn(&Change)>,
1376 /// Called after each successful realloc.
1377 pub resize: Option<fn(&Change)>,
1378 }
1379
1380 impl Callbacks {
1381 fn handler(&self, kind: ChangeKind) -> Option<fn(&Change)> {
1382 match kind {
1383 ChangeKind::Allocate => self.allocate,
1384 ChangeKind::Free => self.free,
1385 ChangeKind::Resize => self.resize,
1386 }
1387 }
1388 }
1389
1390 /// The single C-ABI entry point for all three slots. `arg` is the `&'static Callbacks`
1391 /// the caller handed to [`set_callbacks`]; the kind comes out of the change record, so
1392 /// one trampoline serves every slot.
1393 unsafe extern "C" fn dispatch(change: *const sys::mi_memory_change_t, arg: *mut c_void) {
1394 if change.is_null() || arg.is_null() {
1395 return;
1396 }
1397 let raw = unsafe { &*change };
1398 let callbacks = unsafe { &*(arg as *const Callbacks) };
1399 // An unknown kind means the C enum grew: ignore it rather than guessing.
1400 let Some(kind) = ChangeKind::from_raw(raw.kind) else {
1401 return;
1402 };
1403 let Some(handler) = callbacks.handler(kind) else {
1404 return;
1405 };
1406 let change = Change {
1407 kind,
1408 total_bytes: raw.total_bytes,
1409 delta_bytes: raw.delta_bytes,
1410 request_size: raw.request_size,
1411 };
1412 // A panic must not unwind across the C frame that called us.
1413 let _ = catch_unwind(AssertUnwindSafe(|| handler(&change)));
1414 }
1415
1416 /// Install `callbacks`, replacing any previous table. Returns `false` if the C library
1417 /// refused the table.
1418 ///
1419 /// `'static` is what makes this safe: the C side keeps the pointer until the table is
1420 /// replaced or cleared, which is exactly the header's "`arg` pointers are caller-owned
1421 /// and must stay valid" requirement.
1422 ///
1423 /// Callbacks run with no allocator locks held and **may** allocate, but a hook that
1424 /// fires while another hook's callback is running on the same thread is suppressed —
1425 /// so bytes a callback itself allocates never reach the running totals. Keep them
1426 /// short, and let them return normally: a panic is caught and swallowed here, but a
1427 /// C `longjmp` out of one is unsupported.
1428 pub fn set_callbacks(callbacks: &'static Callbacks) -> bool {
1429 let mut raw = sys::mi_memory_callbacks_t {
1430 handlers: [None; sys::MI_MEMORY_CHANGE_COUNT],
1431 args: [core::ptr::null_mut(); sys::MI_MEMORY_CHANGE_COUNT],
1432 };
1433 let arg = (callbacks as *const Callbacks).cast_mut().cast::<c_void>();
1434 for kind in [ChangeKind::Allocate, ChangeKind::Free, ChangeKind::Resize] {
1435 if callbacks.handler(kind).is_some() {
1436 raw.handlers[kind.slot()] = Some(dispatch);
1437 raw.args[kind.slot()] = arg;
1438 }
1439 }
1440 unsafe { sys::mi_memory_set_callbacks(&raw const raw) }
1441 }
1442
1443 /// Remove every installed callback. Accounting (and [`snapshot`]) keeps working.
1444 pub fn clear_callbacks() -> bool {
1445 unsafe { sys::mi_memory_set_callbacks(core::ptr::null()) }
1446 }
1447
1448 unsafe extern "C" fn visit_trampoline<F>(
1449 allocation: *mut c_void,
1450 usable_size: usize,
1451 arg: *mut c_void,
1452 ) -> bool
1453 where
1454 F: FnMut(*mut u8, usize) -> bool,
1455 {
1456 let visitor = unsafe { &mut *(arg as *mut F) };
1457 catch_unwind(AssertUnwindSafe(|| visitor(allocation.cast(), usable_size))).unwrap_or(false)
1458 }
1459
1460 /// Walk the live allocations this thread may safely observe, calling `visitor` with
1461 /// each one's address and usable size. Return `false` from `visitor` to stop early.
1462 ///
1463 /// Diagnostics only. This is **not** a consistent global snapshot: it is built on
1464 /// `mi_heap_visit_blocks`, so another thread may free a reported allocation the
1465 /// instant the callback begins.
1466 ///
1467 /// # Safety
1468 ///
1469 /// - `visitor` must not allocate, free, or otherwise reenter mimalloc while the walk
1470 /// is active — that includes anything that allocates indirectly, such as `println!`,
1471 /// growing a `Vec`, or formatting. Collect into a fixed-size buffer, or into
1472 /// [`crate::unwrapped_malloc`] memory, and process it after this returns.
1473 /// - `visitor` must not panic. Raising a panic allocates its payload and its message
1474 /// through the global allocator, which reenters mimalloc in the middle of the walk
1475 /// -- the very thing the bullet above forbids. The `catch_unwind` inside the
1476 /// trampoline stops the unwind from crossing the C frame; it does **not** and
1477 /// cannot prevent that allocation, which has already happened by the time it runs.
1478 /// Report failures by setting a flag the caller reads after the walk returns.
1479 /// - The pointers handed to `visitor` must not be dereferenced, retained, or freed:
1480 /// they may already be dead. Treat them as addresses, not as references.
1481 /// - No other thread may be freeing into the heaps being walked (the
1482 /// `mi_heap_visit_blocks` precondition; see `include/mimalloc.h`).
1483 pub unsafe fn visit_live_allocations<F>(mut visitor: F) -> bool
1484 where
1485 F: FnMut(*mut u8, usize) -> bool,
1486 {
1487 unsafe {
1488 sys::mi_memory_visit_live_allocations(
1489 visit_trampoline::<F>,
1490 (&raw mut visitor).cast::<c_void>(),
1491 )
1492 }
1493 }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498 use super::*;
1499 use std::sync::Mutex;
1500
1501 // The profiler is process-global state, and unit tests within this
1502 // binary may run concurrently by default, so serialize everything that
1503 // starts/stops it. `unwrap_or_else` rides through a poisoned lock rather
1504 // than cascading a single panicking test into every other one.
1505 #[cfg(feature = "pprof")]
1506 static PROF_TEST_LOCK: Mutex<()> = Mutex::new(());
1507 static DHAT_TEST_LOCK: Mutex<()> = Mutex::new(());
1508
1509 #[cfg(feature = "pprof")]
1510 fn reset_profiler() {
1511 if prof::is_enabled() {
1512 prof::stop();
1513 }
1514 }
1515
1516 #[test]
1517 #[cfg(feature = "pprof")]
1518 fn enable_heap_profiling_with_default_config_starts_profiler() {
1519 let _guard = PROF_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1520 reset_profiler();
1521
1522 let config = ProfConfig::default();
1523 assert!(enable_heap_profiling_with(&config));
1524 assert!(prof::is_enabled());
1525
1526 prof::stop();
1527 }
1528
1529 #[test]
1530 #[cfg(feature = "pprof")]
1531 fn enable_heap_profiling_with_override_mode_sets_sample_interval() {
1532 let _guard = PROF_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1533 reset_profiler();
1534
1535 let config = ProfConfig {
1536 mode: ProfConfigMode::Override,
1537 sample_interval: Some(4096),
1538 ..Default::default()
1539 };
1540 assert!(enable_heap_profiling_with(&config));
1541 assert!(prof::is_enabled());
1542 assert_eq!(prof::stats().sample_rate, 4096);
1543
1544 prof::stop();
1545 }
1546
1547 #[test]
1548 #[cfg(not(feature = "pprof"))]
1549 fn heap_profiling_is_unavailable_when_compiled_out() {
1550 assert!(!enable_heap_profiling_with(&ProfConfig::default()));
1551 assert!(!prof::is_enabled());
1552 }
1553
1554 #[test]
1555 fn unwrapped_malloc_write_realloc_grow_verify_free() {
1556 unsafe {
1557 let size = 64usize;
1558 let p = unwrapped_malloc(size, 0);
1559 assert!(!p.is_null());
1560
1561 for i in 0..size {
1562 *p.add(i) = (i % 256) as u8;
1563 }
1564
1565 let new_size = 256usize;
1566 let p2 = unwrapped_realloc(p, new_size, 0);
1567 assert!(!p2.is_null());
1568
1569 for i in 0..size {
1570 assert_eq!(*p2.add(i), (i % 256) as u8);
1571 }
1572
1573 unwrapped_free(p2);
1574 }
1575 }
1576
1577 #[test]
1578 fn unwrapped_free_null_is_noop() {
1579 unsafe {
1580 unwrapped_free(core::ptr::null_mut());
1581 }
1582 }
1583
1584 #[test]
1585 fn unwrapped_malloc_rejects_non_power_of_two_alignment() {
1586 unsafe {
1587 let p = unwrapped_malloc(16, 3);
1588 assert!(p.is_null());
1589 }
1590 }
1591
1592 #[test]
1593 fn unwrapped_realloc_with_null_ptr_behaves_like_malloc() {
1594 unsafe {
1595 let p = unwrapped_realloc(core::ptr::null_mut(), 32, 0);
1596 assert!(!p.is_null());
1597 unwrapped_free(p);
1598 }
1599 }
1600
1601 #[test]
1602 fn unwrapped_realloc_with_zero_size_frees_and_returns_null() {
1603 unsafe {
1604 let p = unwrapped_malloc(32, 0);
1605 assert!(!p.is_null());
1606 let p2 = unwrapped_realloc(p, 0, 0);
1607 assert!(p2.is_null());
1608 }
1609 }
1610
1611 #[test]
1612 fn dhat_controls_report_lifecycle() {
1613 let _guard = DHAT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1614 if dhat::is_enabled() {
1615 dhat::stop();
1616 }
1617 assert!(dhat::start());
1618 let active = dhat::stats();
1619 assert!(active.enabled);
1620 dhat::stop();
1621 assert!(!dhat::is_enabled());
1622 assert!(!dhat::stats().enabled);
1623 }
1624 #[test]
1625 fn heap_dump_json_reports_well_formed_json_with_current_heap() {
1626 // The default/main heap always has at least one live allocation by the time any
1627 // Rust test runs (the runtime itself allocates), so a pages-only dump of the
1628 // current subprocess must come back non-empty and syntactically balanced.
1629 let json = heap_dump_json(false, false).expect("heap_dump_json should not fail");
1630 assert!(json.contains("\"heaps\""));
1631 assert!(!json.contains("\"blocks\""));
1632 let opens = json.matches('{').count();
1633 let closes = json.matches('}').count();
1634 assert_eq!(opens, closes);
1635
1636 let with_blocks = heap_dump_json(true, true).expect("heap_dump_json should not fail");
1637 assert!(with_blocks.contains("\"blocks\""));
1638 }
1639}