Skip to main content

pprof_alloc/
allocator.rs

1use crate::stats;
2use anyhow::Result;
3use prometheus_client::collector::Collector;
4use prometheus_client::encoding::DescriptorEncoder;
5use prometheus_client::encoding::EncodeMetric;
6use prometheus_client::metrics::gauge::ConstGauge;
7use prometheus_client::metrics::info::Info;
8use serde::Serialize;
9use std::sync::atomic::{AtomicU8, Ordering};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AllocatorKind {
14	Undeclared,
15	Glibc,
16	Jemalloc,
17	Mimalloc,
18}
19
20#[derive(Clone, Debug, Serialize)]
21pub struct AllocatorSnapshot {
22	pub kind: AllocatorKind,
23	pub comparable: Option<AllocatorComparisonStats>,
24	pub specific: Option<AllocatorSpecificDetails>,
25}
26
27#[derive(Debug, Clone)]
28pub struct PrometheusCollector {}
29
30impl PrometheusCollector {
31	pub fn register(registry: &mut prometheus_client::registry::Registry) {
32		registry.register_collector(Box::new(Self {}))
33	}
34}
35
36impl Collector for PrometheusCollector {
37	fn encode(&self, mut encoder: DescriptorEncoder) -> Result<(), std::fmt::Error> {
38		let snapshot = snapshot();
39		let info_metric = Info::new(vec![("allocator", snapshot.kind.as_str())]);
40		let info_encoder = encoder.encode_descriptor(
41			"allocator_info",
42			"allocator identity for this process",
43			None,
44			info_metric.metric_type(),
45		)?;
46		info_metric.encode(info_encoder)?;
47		let configured_metric = ConstGauge::new(u64::from(snapshot.kind != AllocatorKind::Undeclared));
48		let configured_encoder = encoder.encode_descriptor(
49			"allocator_configured",
50			"whether allocator kind was explicitly declared for this process",
51			None,
52			configured_metric.metric_type(),
53		)?;
54		configured_metric.encode(configured_encoder)?;
55
56		let mut encode = |value: Option<u64>, name: &'static str, help: &str| {
57			let Some(value) = prometheus_gauge_value(value) else {
58				return Ok(());
59			};
60			let metric = ConstGauge::new(value);
61			let metric_encoder = encoder.encode_descriptor(name, help, None, metric.metric_type())?;
62			metric.encode(metric_encoder)?;
63			Ok(())
64		};
65
66		let Some(comparable) = snapshot.comparable.as_ref() else {
67			return Ok(());
68		};
69
70		encode(
71			comparable.allocated_bytes,
72			"allocator_allocated_bytes",
73			"bytes allocated according to the current allocator",
74		)?;
75		encode(
76			comparable.active_bytes,
77			"allocator_active_bytes",
78			"bytes currently active according to the current allocator",
79		)?;
80		encode(
81			comparable.resident_bytes,
82			"allocator_resident_bytes",
83			"resident bytes attributed to the current allocator",
84		)?;
85		encode(
86			comparable.mapped_bytes,
87			"allocator_mapped_bytes",
88			"bytes mapped or reserved by the current allocator",
89		)?;
90		encode(
91			comparable.retained_bytes,
92			"allocator_retained_bytes",
93			"bytes retained but not currently active according to the current allocator",
94		)?;
95		encode(
96			comparable.metadata_bytes,
97			"allocator_metadata_bytes",
98			"bytes used for allocator metadata",
99		)?;
100		encode(
101			comparable.committed_bytes,
102			"allocator_committed_bytes",
103			"bytes committed by the current allocator",
104		)?;
105		encode(
106			comparable.allocator_structures,
107			"allocator_structures",
108			"allocator structures such as heaps or arenas",
109		)?;
110		Ok(())
111	}
112}
113
114fn prometheus_gauge_value(value: Option<u64>) -> Option<u64> {
115	value.filter(|value| *value != u64::MAX)
116}
117
118#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
119pub struct AllocatorComparisonStats {
120	pub allocated_bytes: Option<u64>,
121	pub active_bytes: Option<u64>,
122	pub resident_bytes: Option<u64>,
123	pub mapped_bytes: Option<u64>,
124	pub retained_bytes: Option<u64>,
125	pub metadata_bytes: Option<u64>,
126	pub committed_bytes: Option<u64>,
127	pub allocator_structures: Option<u64>,
128}
129
130#[derive(Clone, Debug, Serialize)]
131#[serde(tag = "kind", rename_all = "snake_case")]
132pub enum AllocatorSpecificDetails {
133	Glibc(GlibcStats),
134	#[cfg(feature = "allocator-jemalloc")]
135	Jemalloc(JemallocStats),
136	#[cfg(feature = "allocator-mimalloc")]
137	Mimalloc(MimallocStats),
138}
139
140#[derive(Clone, Debug, Serialize)]
141pub struct GlibcStats {
142	pub system_max: u64,
143	pub system_current: u64,
144	pub free_bytes: u64,
145	pub mmap_current: u64,
146	pub in_use_bytes: u64,
147	pub heaps: u64,
148}
149
150impl From<&stats::malloc::MallocInfo> for GlibcStats {
151	fn from(info: &stats::malloc::MallocInfo) -> Self {
152		Self {
153			system_max: info.system_max(),
154			system_current: info.system_current(),
155			free_bytes: info.free_bytes(),
156			mmap_current: info.mmap_bytes(),
157			in_use_bytes: info.in_use_bytes(),
158			heaps: info.heaps(),
159		}
160	}
161}
162
163impl From<&GlibcStats> for AllocatorComparisonStats {
164	fn from(stats: &GlibcStats) -> Self {
165		Self {
166			allocated_bytes: Some(stats.in_use_bytes),
167			active_bytes: None,
168			resident_bytes: None,
169			mapped_bytes: Some(stats.system_current.saturating_add(stats.mmap_current)),
170			retained_bytes: Some(stats.free_bytes),
171			metadata_bytes: None,
172			committed_bytes: None,
173			allocator_structures: Some(stats.heaps),
174		}
175	}
176}
177
178#[cfg(feature = "allocator-jemalloc")]
179#[derive(Clone, Debug, Serialize)]
180pub struct JemallocStats {
181	pub allocated: u64,
182	pub active: u64,
183	pub metadata: u64,
184	pub resident: u64,
185	pub mapped: u64,
186	pub retained: u64,
187	pub background_thread: bool,
188}
189
190#[cfg(feature = "allocator-jemalloc")]
191impl From<&JemallocStats> for AllocatorComparisonStats {
192	fn from(stats: &JemallocStats) -> Self {
193		Self {
194			allocated_bytes: Some(stats.allocated),
195			active_bytes: Some(stats.active),
196			resident_bytes: Some(stats.resident),
197			mapped_bytes: Some(stats.mapped),
198			retained_bytes: Some(stats.retained),
199			metadata_bytes: Some(stats.metadata),
200			committed_bytes: None,
201			allocator_structures: None,
202		}
203	}
204}
205
206#[cfg(feature = "allocator-mimalloc")]
207#[derive(Clone, Debug, Serialize)]
208pub struct MimallocStats {
209	pub version: u32,
210	pub reserved_current: u64,
211	pub reserved_peak: u64,
212	pub committed_current: u64,
213	pub committed_peak: u64,
214	pub reset_current: u64,
215	pub purged_current: u64,
216	pub process_rss_current: u64,
217	pub process_rss_peak: u64,
218	pub process_commit_current: u64,
219	pub process_commit_peak: u64,
220	pub page_faults: u64,
221	pub arenas: u64,
222}
223
224#[cfg(feature = "allocator-mimalloc")]
225impl From<&MimallocStats> for AllocatorComparisonStats {
226	fn from(stats: &MimallocStats) -> Self {
227		Self {
228			allocated_bytes: None,
229			active_bytes: None,
230			resident_bytes: Some(stats.process_rss_current),
231			mapped_bytes: Some(stats.reserved_current),
232			retained_bytes: None,
233			metadata_bytes: None,
234			committed_bytes: Some(stats.committed_current),
235			allocator_structures: Some(stats.arenas),
236		}
237	}
238}
239
240#[cfg(feature = "allocator-mimalloc")]
241#[repr(C)]
242struct MiStatCount {
243	total: i64,
244	peak: i64,
245	current: i64,
246}
247
248#[cfg(feature = "allocator-mimalloc")]
249#[repr(C)]
250struct MiStatCounter {
251	total: i64,
252}
253
254#[cfg(feature = "allocator-mimalloc")]
255#[repr(C)]
256struct MiStats {
257	version: i32,
258	pages: MiStatCount,
259	reserved: MiStatCount,
260	committed: MiStatCount,
261	reset: MiStatCount,
262	purged: MiStatCount,
263	page_committed: MiStatCount,
264	pages_abandoned: MiStatCount,
265	threads: MiStatCount,
266	malloc_normal: MiStatCount,
267	malloc_huge: MiStatCount,
268	malloc_requested: MiStatCount,
269	mmap_calls: MiStatCounter,
270	commit_calls: MiStatCounter,
271	reset_calls: MiStatCounter,
272	purge_calls: MiStatCounter,
273	arena_count: MiStatCounter,
274	malloc_normal_count: MiStatCounter,
275	malloc_huge_count: MiStatCounter,
276	malloc_guarded_count: MiStatCounter,
277	arena_rollback_count: MiStatCounter,
278	arena_purges: MiStatCounter,
279	pages_extended: MiStatCounter,
280	pages_retire: MiStatCounter,
281	page_searches: MiStatCounter,
282	segments: MiStatCount,
283	segments_abandoned: MiStatCount,
284	segments_cache: MiStatCount,
285	segments_reserved: MiStatCount,
286	pages_reclaim_on_alloc: MiStatCounter,
287	pages_reclaim_on_free: MiStatCounter,
288	pages_reabandon_full: MiStatCounter,
289	pages_unabandon_busy_wait: MiStatCounter,
290	stat_reserved: [MiStatCount; 4],
291	stat_counter_reserved: [MiStatCounter; 4],
292	malloc_bins: [MiStatCount; 74],
293	page_bins: [MiStatCount; 74],
294}
295
296static CONFIGURED_ALLOCATOR: AtomicU8 = AtomicU8::new(AllocatorKind::Undeclared.as_u8());
297
298impl AllocatorKind {
299	const fn as_u8(self) -> u8 {
300		match self {
301			Self::Undeclared => 0,
302			Self::Glibc => 1,
303			Self::Jemalloc => 2,
304			Self::Mimalloc => 3,
305		}
306	}
307
308	const fn from_u8(value: u8) -> Self {
309		match value {
310			1 => Self::Glibc,
311			2 => Self::Jemalloc,
312			3 => Self::Mimalloc,
313			_ => Self::Undeclared,
314		}
315	}
316
317	pub const fn as_str(self) -> &'static str {
318		match self {
319			Self::Undeclared => "undeclared",
320			Self::Glibc => "glibc",
321			Self::Jemalloc => "jemalloc",
322			Self::Mimalloc => "mimalloc",
323		}
324	}
325}
326
327pub fn configure(kind: AllocatorKind) {
328	CONFIGURED_ALLOCATOR.store(kind.as_u8(), Ordering::Release);
329}
330
331pub fn configured() -> AllocatorKind {
332	AllocatorKind::from_u8(CONFIGURED_ALLOCATOR.load(Ordering::Acquire))
333}
334
335pub fn snapshot() -> AllocatorSnapshot {
336	snapshot_for(configured())
337}
338
339pub fn snapshot_for(kind: AllocatorKind) -> AllocatorSnapshot {
340	match kind {
341		AllocatorKind::Undeclared => backend_snapshot(
342			kind,
343			Err(anyhow::anyhow!(
344				"allocator kind is undeclared; add declare_allocator_kind!(...) next to #[global_allocator]"
345			)),
346		),
347		AllocatorKind::Glibc => {
348			let result = glibc_snapshot().map(|stats| {
349				(
350					AllocatorComparisonStats::from(&stats),
351					AllocatorSpecificDetails::Glibc(stats),
352				)
353			});
354			backend_snapshot(kind, result)
355		},
356		AllocatorKind::Jemalloc => backend_snapshot(kind, jemalloc_snapshot_pair()),
357		AllocatorKind::Mimalloc => backend_snapshot(kind, mimalloc_snapshot_pair()),
358	}
359}
360
361fn backend_snapshot(
362	kind: AllocatorKind,
363	result: Result<(AllocatorComparisonStats, AllocatorSpecificDetails)>,
364) -> AllocatorSnapshot {
365	match result {
366		Ok((comparable, specific)) => AllocatorSnapshot {
367			kind,
368			comparable: Some(comparable),
369			specific: Some(specific),
370		},
371		Err(_) => AllocatorSnapshot {
372			kind,
373			comparable: None,
374			specific: None,
375		},
376	}
377}
378
379fn glibc_snapshot() -> Result<GlibcStats> {
380	stats::malloc::info()
381		.map(|info| GlibcStats::from(&info))
382		.map_err(anyhow::Error::from)
383}
384
385#[cfg(feature = "allocator-jemalloc")]
386fn jemalloc_snapshot_pair() -> Result<(AllocatorComparisonStats, AllocatorSpecificDetails)> {
387	let stats = jemalloc_snapshot()?;
388	Ok((
389		AllocatorComparisonStats::from(&stats),
390		AllocatorSpecificDetails::Jemalloc(stats),
391	))
392}
393
394#[cfg(not(feature = "allocator-jemalloc"))]
395fn jemalloc_snapshot_pair() -> Result<(AllocatorComparisonStats, AllocatorSpecificDetails)> {
396	anyhow::bail!("jemalloc support is not compiled in; enable the `allocator-jemalloc` feature")
397}
398
399#[cfg(feature = "allocator-jemalloc")]
400fn jemalloc_snapshot() -> Result<JemallocStats> {
401	use tikv_jemalloc_ctl::{background_thread, epoch, stats};
402
403	epoch::advance().map_err(anyhow::Error::from)?;
404
405	Ok(JemallocStats {
406		allocated: stats::allocated::read().map_err(anyhow::Error::from)? as u64,
407		active: stats::active::read().map_err(anyhow::Error::from)? as u64,
408		metadata: stats::metadata::read().map_err(anyhow::Error::from)? as u64,
409		resident: stats::resident::read().map_err(anyhow::Error::from)? as u64,
410		mapped: stats::mapped::read().map_err(anyhow::Error::from)? as u64,
411		retained: stats::retained::read().map_err(anyhow::Error::from)? as u64,
412		background_thread: background_thread::read().unwrap_or(false),
413	})
414}
415
416#[cfg(feature = "allocator-mimalloc")]
417fn mimalloc_snapshot_pair() -> Result<(AllocatorComparisonStats, AllocatorSpecificDetails)> {
418	let stats = mimalloc_snapshot()?;
419	Ok((
420		AllocatorComparisonStats::from(&stats),
421		AllocatorSpecificDetails::Mimalloc(stats),
422	))
423}
424
425#[cfg(not(feature = "allocator-mimalloc"))]
426fn mimalloc_snapshot_pair() -> Result<(AllocatorComparisonStats, AllocatorSpecificDetails)> {
427	anyhow::bail!("mimalloc support is not compiled in; enable the `allocator-mimalloc` feature")
428}
429
430#[cfg(feature = "allocator-mimalloc")]
431fn mimalloc_snapshot() -> Result<MimallocStats> {
432	use libmimalloc_sys::mi_process_info;
433
434	unsafe extern "C" {
435		// `libmimalloc-sys` exposes most of the extended API we use, but it does
436		// not currently bind `mi_stats_get`.
437		fn mi_stats_get(stats_size: usize, stats: *mut MiStats);
438	}
439
440	let mut stats = MiStats {
441		version: 0,
442		pages: zero_count(),
443		reserved: zero_count(),
444		committed: zero_count(),
445		reset: zero_count(),
446		purged: zero_count(),
447		page_committed: zero_count(),
448		pages_abandoned: zero_count(),
449		threads: zero_count(),
450		malloc_normal: zero_count(),
451		malloc_huge: zero_count(),
452		malloc_requested: zero_count(),
453		mmap_calls: zero_counter(),
454		commit_calls: zero_counter(),
455		reset_calls: zero_counter(),
456		purge_calls: zero_counter(),
457		arena_count: zero_counter(),
458		malloc_normal_count: zero_counter(),
459		malloc_huge_count: zero_counter(),
460		malloc_guarded_count: zero_counter(),
461		arena_rollback_count: zero_counter(),
462		arena_purges: zero_counter(),
463		pages_extended: zero_counter(),
464		pages_retire: zero_counter(),
465		page_searches: zero_counter(),
466		segments: zero_count(),
467		segments_abandoned: zero_count(),
468		segments_cache: zero_count(),
469		segments_reserved: zero_count(),
470		pages_reclaim_on_alloc: zero_counter(),
471		pages_reclaim_on_free: zero_counter(),
472		pages_reabandon_full: zero_counter(),
473		pages_unabandon_busy_wait: zero_counter(),
474		stat_reserved: [zero_count(), zero_count(), zero_count(), zero_count()],
475		stat_counter_reserved: [
476			zero_counter(),
477			zero_counter(),
478			zero_counter(),
479			zero_counter(),
480		],
481		malloc_bins: std::array::from_fn(|_| zero_count()),
482		page_bins: std::array::from_fn(|_| zero_count()),
483	};
484
485	let mut elapsed_msecs = 0usize;
486	let mut user_msecs = 0usize;
487	let mut system_msecs = 0usize;
488	let mut current_rss = 0usize;
489	let mut peak_rss = 0usize;
490	let mut current_commit = 0usize;
491	let mut peak_commit = 0usize;
492	let mut page_faults = 0usize;
493
494	unsafe {
495		mi_stats_get(std::mem::size_of::<MiStats>(), &mut stats);
496		mi_process_info(
497			&mut elapsed_msecs,
498			&mut user_msecs,
499			&mut system_msecs,
500			&mut current_rss,
501			&mut peak_rss,
502			&mut current_commit,
503			&mut peak_commit,
504			&mut page_faults,
505		);
506	}
507
508	let _ = (elapsed_msecs, user_msecs, system_msecs);
509
510	if stats.version == 0 {
511		anyhow::bail!("mimalloc statistics are unavailable");
512	}
513
514	Ok(MimallocStats {
515		version: stats.version as u32,
516		reserved_current: stats.reserved.current.max(0) as u64,
517		reserved_peak: stats.reserved.peak.max(0) as u64,
518		committed_current: stats.committed.current.max(0) as u64,
519		committed_peak: stats.committed.peak.max(0) as u64,
520		reset_current: stats.reset.current.max(0) as u64,
521		purged_current: stats.purged.current.max(0) as u64,
522		process_rss_current: sanitize_mimalloc_process_value(current_rss),
523		process_rss_peak: sanitize_mimalloc_process_value(peak_rss),
524		process_commit_current: sanitize_mimalloc_process_value(current_commit),
525		process_commit_peak: sanitize_mimalloc_process_value(peak_commit),
526		page_faults: page_faults as u64,
527		arenas: stats.arena_count.total.max(0) as u64,
528	})
529}
530
531#[cfg(feature = "allocator-mimalloc")]
532fn sanitize_mimalloc_process_value(value: usize) -> u64 {
533	if value == usize::MAX { 0 } else { value as u64 }
534}
535
536#[cfg(feature = "allocator-mimalloc")]
537const fn zero_count() -> MiStatCount {
538	MiStatCount {
539		total: 0,
540		peak: 0,
541		current: 0,
542	}
543}
544
545#[cfg(feature = "allocator-mimalloc")]
546const fn zero_counter() -> MiStatCounter {
547	MiStatCounter { total: 0 }
548}
549
550#[cfg(test)]
551mod tests {
552	use super::*;
553	use parking_lot::Mutex;
554	use prometheus_client::encoding::text::encode;
555	use prometheus_client::registry::Registry;
556
557	static TEST_GUARD: Mutex<()> = Mutex::new(());
558
559	#[test]
560	fn snapshot_reports_undeclared_allocator_without_stats() {
561		let _guard = TEST_GUARD.lock();
562		configure(AllocatorKind::Undeclared);
563
564		let snapshot = snapshot();
565		assert_eq!(snapshot.kind, AllocatorKind::Undeclared);
566		assert!(snapshot.comparable.is_none());
567		assert!(snapshot.specific.is_none());
568	}
569
570	#[test]
571	fn prometheus_collector_emits_allocator_info_metric() {
572		let _guard = TEST_GUARD.lock();
573		configure(AllocatorKind::Glibc);
574
575		let mut registry = Registry::default();
576		PrometheusCollector::register(&mut registry);
577
578		let mut output = String::new();
579		encode(&mut output, &registry).expect("allocator metrics should encode");
580
581		assert!(output.contains("# TYPE allocator_info info"));
582		assert!(output.contains("allocator_info_info{allocator=\"glibc\"} 1"));
583		assert!(output.contains("allocator_configured 1"));
584	}
585
586	#[test]
587	fn prometheus_collector_emits_undeclared_allocator_signal() {
588		let _guard = TEST_GUARD.lock();
589		configure(AllocatorKind::Undeclared);
590
591		let mut registry = Registry::default();
592		PrometheusCollector::register(&mut registry);
593
594		let mut output = String::new();
595		encode(&mut output, &registry).expect("allocator metrics should encode");
596
597		assert!(output.contains("allocator_info_info{allocator=\"undeclared\"} 1"));
598		assert!(output.contains("allocator_configured 0"));
599	}
600
601	#[test]
602	fn glibc_comparison_stats_use_in_use_and_free_bytes() {
603		let comparable = AllocatorComparisonStats::from(&GlibcStats {
604			system_max: 8192,
605			system_current: 4096,
606			free_bytes: 1024,
607			mmap_current: 512,
608			in_use_bytes: 3584,
609			heaps: 3,
610		});
611
612		assert_eq!(comparable.allocated_bytes, Some(3584));
613		assert_eq!(comparable.mapped_bytes, Some(4608));
614		assert_eq!(comparable.retained_bytes, Some(1024));
615		assert_eq!(comparable.allocator_structures, Some(3));
616	}
617
618	#[test]
619	fn prometheus_gauge_value_rejects_u64_max() {
620		assert_eq!(prometheus_gauge_value(Some(u64::MAX)), None);
621		assert_eq!(
622			prometheus_gauge_value(Some(u64::MAX - 1)),
623			Some(u64::MAX - 1)
624		);
625		assert_eq!(prometheus_gauge_value(None), None);
626	}
627
628	#[cfg(feature = "allocator-mimalloc")]
629	#[test]
630	fn mimalloc_comparison_stats_keep_process_level_fields() {
631		let comparable = AllocatorComparisonStats::from(&MimallocStats {
632			version: 1,
633			reserved_current: 8192,
634			reserved_peak: 12288,
635			committed_current: 4096,
636			committed_peak: 6144,
637			reset_current: 0,
638			purged_current: 0,
639			process_rss_current: 3072,
640			process_rss_peak: 6144,
641			process_commit_current: 4096,
642			process_commit_peak: 8192,
643			page_faults: 0,
644			arenas: 2,
645		});
646
647		assert_eq!(comparable.allocated_bytes, None);
648		assert_eq!(comparable.resident_bytes, Some(3072));
649		assert_eq!(comparable.mapped_bytes, Some(8192));
650		assert_eq!(comparable.committed_bytes, Some(4096));
651		assert_eq!(comparable.allocator_structures, Some(2));
652	}
653
654	#[cfg(feature = "allocator-mimalloc")]
655	#[test]
656	fn mimalloc_process_values_do_not_preserve_wrapped_max() {
657		assert_eq!(sanitize_mimalloc_process_value(usize::MAX), 0);
658		assert_eq!(sanitize_mimalloc_process_value(4096), 4096);
659	}
660
661	#[cfg(feature = "allocator-mimalloc")]
662	#[test]
663	fn mimalloc_comparison_stats_use_sanitized_process_rss() {
664		let comparable = AllocatorComparisonStats::from(&MimallocStats {
665			version: 1,
666			reserved_current: 8192,
667			reserved_peak: 12288,
668			committed_current: 4096,
669			committed_peak: 6144,
670			reset_current: 0,
671			purged_current: 0,
672			process_rss_current: 0,
673			process_rss_peak: 6144,
674			process_commit_current: 4096,
675			process_commit_peak: 8192,
676			page_faults: 0,
677			arenas: 2,
678		});
679
680		assert_eq!(comparable.resident_bytes, Some(0));
681		assert_eq!(comparable.allocated_bytes, None);
682		assert_eq!(comparable.committed_bytes, Some(4096));
683	}
684}