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