azul_layout/probe.rs
1//! Optional fine-grained timing + RSS instrumentation.
2//!
3//! Behind the `probe` feature flag every [`Probe::span`] returns a guard
4//! that records the elapsed wall-clock on `Drop`, and
5//! [`Probe::sample_rss`] records a labelled RSS checkpoint. Events are
6//! buffered in a per-thread [`Vec`] and drained by the consumer with
7//! [`Probe::drain`].
8//!
9//! With the feature off every method is a `#[inline]` no-op so
10//! release builds without the feature pay zero cost.
11//!
12//! Consumer (e.g. servo-shot) groups drained events by name to produce
13//! the per-phase averages / p99s in its trace report.
14
15use core::marker::PhantomData;
16
17// WASM gate: `Instant::now()` panics on browser WASM (no monotonic clock)
18// and `libc::getrusage` isn't available, so on `target_family = "wasm"`
19// we drop to the no-op stubs even when the `probe` feature is on.
20// `AZ_PROFILE=cpu` then prints "(probe unavailable on this target)"
21// rather than crashing.
22
23// [WEB-LIFT 2026-06-11] `web_lift` also forces the no-op imp: the real
24// module is Instant::now (mach-time syscall, out-of-image when lifted) +
25// thread-local pushes + first-access dtor registration (`_tlv_atexit`).
26// With the TLV emulation in place TLS "works", which flips these from
27// harmlessly-failing (`try_with` Err) to actually-running — and the
28// mach/atexit extern calls inside are unliftable. Profiling is
29// meaningless in lifted wasm; the dylib built with `web-transpiler*`
30// (which enables `web_lift`) is the web-server build, so desktop
31// release builds keep real probes.
32#[cfg(all(
33 feature = "probe",
34 not(target_family = "wasm"),
35 not(feature = "web_lift")
36))]
37mod imp {
38 use std::cell::RefCell;
39 use std::time::Instant;
40
41 thread_local! {
42 static EVENTS: RefCell<Vec<super::Event>> = const { RefCell::new(Vec::new()) };
43 }
44
45 /// RAII guard that records its name + elapsed nanos on drop.
46 pub struct Span {
47 pub(crate) name: &'static str,
48 pub(crate) start: Instant,
49 }
50
51 impl Drop for Span {
52 fn drop(&mut self) {
53 let dur_ns = self.start.elapsed().as_nanos() as u64;
54 // try_with (not with): the lifted-to-wasm web backend has no real
55 // TLS, so `with` hits panic_access_error. These probe accesses are
56 // inlined into layout_dom_recursive/layout_document, so they can't
57 // be stubbed at the symbol level — use the non-panicking access.
58 let _ = EVENTS.try_with(|cell| {
59 cell.borrow_mut().push(super::Event {
60 name: self.name,
61 kind: super::EventKind::Span { dur_ns },
62 });
63 });
64 }
65 }
66
67 pub(super) fn open(name: &'static str) -> Span {
68 Span { name, start: Instant::now() }
69 }
70
71 pub(super) fn sample_rss(label: &'static str, bytes: u64) {
72 // try_with: see Span::drop — no real TLS in the lifted wasm backend.
73 let _ = EVENTS.try_with(|cell| {
74 cell.borrow_mut().push(super::Event {
75 name: label,
76 kind: super::EventKind::Rss { bytes },
77 });
78 });
79 }
80
81 pub(super) fn drain() -> Vec<super::Event> {
82 EVENTS
83 .try_with(|cell| core::mem::take(&mut *cell.borrow_mut()))
84 .unwrap_or_default()
85 }
86
87 pub(super) fn drop_events() {
88 let _ = EVENTS.try_with(|cell| cell.borrow_mut().clear());
89 }
90
91 pub(super) fn peek_len() -> usize {
92 EVENTS.try_with(|cell| cell.borrow().len()).unwrap_or(0)
93 }
94
95 pub(super) fn enabled() -> bool {
96 true
97 }
98}
99
100#[cfg(any(
101 not(feature = "probe"),
102 target_family = "wasm",
103 feature = "web_lift"
104))]
105mod imp {
106 #[derive(Debug)]
107 pub struct Span;
108
109 impl Drop for Span {
110 #[inline]
111 fn drop(&mut self) {}
112 }
113
114 #[inline]
115 pub(super) const fn open(_name: &'static str) -> Span {
116 Span
117 }
118
119 #[inline]
120 pub(super) const fn sample_rss(_label: &'static str, _bytes: u64) {}
121
122 #[inline]
123 pub(super) const fn drain() -> Vec<super::Event> {
124 Vec::new()
125 }
126
127 #[inline]
128 pub(super) const fn drop_events() {}
129
130 #[inline]
131 pub(super) const fn peek_len() -> usize { 0 }
132
133 #[inline]
134 pub(super) const fn enabled() -> bool {
135 false
136 }
137}
138
139/// Drained probe event. `Vec<Event>` is what consumers walk to render
140/// trace summaries; the order is the order events fired in.
141#[derive(Copy, Debug, Clone)]
142pub struct Event {
143 pub name: &'static str,
144 pub kind: EventKind,
145}
146
147#[derive(Copy, Debug, Clone)]
148pub enum EventKind {
149 /// A timed scope's wall-clock duration.
150 Span { dur_ns: u64 },
151 /// A labelled RSS checkpoint.
152 Rss { bytes: u64 },
153}
154
155/// Re-exported guard. Held by the caller of [`Probe::span`].
156pub use imp::Span;
157
158/// Probe API. All methods are no-ops without the `probe` feature.
159#[derive(Copy, Clone, Debug)]
160pub struct Probe {
161 _no_construct: PhantomData<()>,
162}
163
164impl Probe {
165 /// Open a timed span. The returned guard records its name + nanos
166 /// on drop into the thread-local event buffer.
167 #[inline]
168 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
169 #[allow(clippy::missing_const_for_fn)]
170 #[must_use] pub fn span(name: &'static str) -> Span {
171 imp::open(name)
172 }
173
174 /// Record an RSS checkpoint with the given label + byte count. The
175 /// caller supplies the bytes (this module does not depend on
176 /// platform RSS readers) so consumers can use whatever measurement
177 /// helper they own.
178 #[inline]
179 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
180 #[allow(clippy::missing_const_for_fn)]
181 pub fn sample_rss(label: &'static str, bytes: u64) {
182 imp::sample_rss(label, bytes);
183 }
184
185 /// Drain the per-thread event buffer.
186 #[inline]
187 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
188 #[allow(clippy::missing_const_for_fn)]
189 #[must_use] pub fn drain() -> Vec<Event> {
190 imp::drain()
191 }
192
193 /// Discard the per-thread event buffer without allocating a `Vec` to
194 /// hand back. Used by long-running harnesses (e.g. `AZ_E2E_TEST`) that
195 /// want to prevent the thread-local buffer from inflating RSS during
196 /// thousands of layout passes without actually needing the events.
197 #[inline]
198 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
199 #[allow(clippy::missing_const_for_fn)]
200 pub fn drop_events() {
201 imp::drop_events();
202 }
203
204 /// Current number of events in the per-thread buffer. Cheap to call.
205 #[inline]
206 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
207 #[allow(clippy::missing_const_for_fn)]
208 #[must_use] pub fn peek_len() -> usize {
209 imp::peek_len()
210 }
211
212 /// Whether the `probe` feature is compiled in.
213 #[inline]
214 // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
215 #[allow(clippy::missing_const_for_fn)]
216 #[must_use] pub fn enabled() -> bool {
217 imp::enabled()
218 }
219}
220
221/// Same monotonic clock used by `font::parsed::monotonic_now_nanos` for
222/// LRU stamping. Re-exported here so any caller that wants raw nanos
223/// without going through a span guard has one source of truth.
224#[inline]
225#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
226pub fn monotonic_now_nanos() -> u64 {
227 use std::sync::OnceLock;
228 use std::time::Instant;
229 static LAUNCH: OnceLock<Instant> = OnceLock::new();
230 let start = LAUNCH.get_or_init(Instant::now);
231 start.elapsed().as_nanos() as u64
232}
233
234/// Format drained probe events as a per-phase timing table to stderr.
235///
236/// Groups `EventKind::Span` by name and prints count / total / avg / p99 /
237/// max in µs. `EventKind::Rss` checkpoints print in wall-clock order with
238/// deltas so allocator purges are visible.
239///
240/// Sorted by total-ns descending so the slowest phase is on top — ideal
241/// for spotting which phase spiked during a stuttering frame.
242///
243/// Called by `AZ_PROFILE=cpu` dumps (both initial layout and relayout),
244/// and also by external consumers like `servo-shot --azul-trace`.
245#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
246/// # Panics
247///
248/// Panics if the collected timing-sample list is empty.
249pub fn print_drained_events(label: &str, events: &[Event]) {
250 use std::collections::BTreeMap;
251
252 if events.is_empty() {
253 if Probe::enabled() {
254 eprintln!("[CPU] {label}: no events recorded this pass");
255 } else {
256 // Feature absent or target-family disabled (WASM): show "???"
257 // instead of a misleading "compile with feature=probe" hint.
258 eprintln!(
259 "[CPU] {label}: probe unavailable on this target (timings = ???)"
260 );
261 }
262 return;
263 }
264
265 let mut spans: BTreeMap<&'static str, Vec<u64>> = BTreeMap::new();
266 let mut rss_marks: Vec<(&'static str, u64)> = Vec::new();
267 for ev in events {
268 match ev.kind {
269 EventKind::Span { dur_ns } => spans.entry(ev.name).or_default().push(dur_ns),
270 EventKind::Rss { bytes } => rss_marks.push((ev.name, bytes)),
271 }
272 }
273
274 let mut rows: Vec<(&'static str, usize, u64, u64, u64, u64)> = spans
275 .into_iter()
276 .map(|(name, mut ns)| {
277 ns.sort_unstable();
278 let n = ns.len();
279 let total: u128 = ns.iter().map(|&x| u128::from(x)).sum();
280 let avg = (total / n.max(1) as u128) as u64;
281 let p99 = ns[(n.saturating_sub(1) * 99) / 100];
282 let max = *ns.last().unwrap();
283 (name, n, total as u64, avg, p99, max)
284 })
285 .collect();
286 rows.sort_by(|a, b| b.2.cmp(&a.2));
287
288 eprintln!("[CPU] === {label} ({} phases) ===", rows.len());
289 eprintln!(
290 "[CPU] {:<28} {:>5} {:>10} {:>9} {:>9} {:>9}",
291 "phase", "n", "total(µs)", "avg(µs)", "p99(µs)", "max(µs)"
292 );
293 for (name, n, total, avg, p99, max) in &rows {
294 eprintln!(
295 "[CPU] {:<28} {:>5} {:>10.1} {:>9.2} {:>9.2} {:>9.2}",
296 name,
297 n,
298 (*total as f64) / 1_000.0,
299 (*avg as f64) / 1_000.0,
300 (*p99 as f64) / 1_000.0,
301 (*max as f64) / 1_000.0,
302 );
303 }
304 if !rss_marks.is_empty() {
305 eprintln!("[CPU] -- RSS checkpoints (wall-clock order) --");
306 let mut prev: Option<u64> = None;
307 for (lbl, bytes) in &rss_marks {
308 let delta = prev
309 .map(|p| {
310 let diff = i128::from(*bytes) - i128::from(p);
311 if diff >= 0 {
312 format!(" (Δ +{:.2} MiB)", diff as f64 / 1_048_576.0)
313 } else {
314 format!(" (Δ -{:.2} MiB)", -diff as f64 / 1_048_576.0)
315 }
316 })
317 .unwrap_or_default();
318 eprintln!(
319 "[CPU] {:<28} {:.2} MiB{}",
320 lbl,
321 *bytes as f64 / 1_048_576.0,
322 delta
323 );
324 prev = Some(*bytes);
325 }
326 }
327}
328
329/// Convenience wrapper: sample the process's **current** resident set
330/// (not peak) via `task_info` on macOS / `/proc/self/statm` on Linux and
331/// push it into the probe event buffer under the given label.
332///
333/// Using current RSS (not `getrusage.ru_maxrss`) is essential so that
334/// allocator purges are visible — peak RSS only moves up. Name kept as
335/// `sample_peak_rss` for backwards compatibility with existing
336/// checkpoint labels; semantically it is "sample current".
337#[inline]
338// const only without the `probe` feature; enabled path calls non-const RSS readers
339#[allow(clippy::missing_const_for_fn)]
340pub fn sample_peak_rss(label: &'static str) {
341 // [WEB-LIFT 2026-06-11] also no-op under web_lift: current_rss_bytes/
342 // peak_rss_bytes_self are mach syscalls (task_info/getrusage) —
343 // out-of-image and unliftable. See the `imp` cfg note above.
344 #[cfg(all(feature = "probe", not(feature = "web_lift")))]
345 {
346 let (current, _virt) = current_rss_bytes();
347 let bytes = if current != 0 { current } else { peak_rss_bytes_self() };
348 Probe::sample_rss(label, bytes);
349 }
350 #[cfg(any(not(feature = "probe"), feature = "web_lift"))]
351 let _ = label;
352}
353
354#[cfg(feature = "probe")]
355pub fn peak_rss_bytes_pub() -> u64 { peak_rss_bytes_self() }
356
357#[cfg(feature = "probe")]
358fn peak_rss_bytes_self() -> u64 {
359 #[cfg(unix)]
360 unsafe {
361 let mut ru: libc::rusage = core::mem::zeroed();
362 if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
363 return 0;
364 }
365 let raw = ru.ru_maxrss as u64;
366 if cfg!(target_os = "macos") { raw } else { raw.saturating_mul(1024) }
367 }
368 #[cfg(not(unix))]
369 {
370 0
371 }
372}
373
374/// Ask the active global allocator to return freed pages to the OS.
375///
376/// - With `allocator_mimalloc` feature: calls `mi_collect(true)`, which
377/// aggressively returns pages (matches `az_purge_allocator` in azul-dll).
378/// - With `allocator_jemalloc` feature: calls `mallctl("arena.0.purge")`.
379/// - Otherwise on macOS: falls back to `malloc_zone_pressure_relief`
380/// which drains the system zone (no-op when a third-party allocator
381/// is the global one — hence the explicit feature flags above).
382/// - Other platforms with default allocator: no-op.
383///
384/// Call after major allocations are freed (e.g. after a layout pass).
385#[inline]
386// const only on the default-allocator no-op path (e.g. Linux); the mimalloc /
387// jemalloc / macOS `malloc_zone_pressure_relief` bodies call non-const fns
388#[allow(clippy::missing_const_for_fn)]
389pub fn hint_purge_allocator() {
390 #[cfg(feature = "allocator_mimalloc")]
391 {
392 // Aggressive purge — returns arenas to the OS when possible.
393 unsafe {
394 libmimalloc_sys::mi_collect(true);
395 }
396 static PURGE_TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
397 if *PURGE_TRACE.get_or_init(azul_core::profile::memory_enabled) {
398 let (rss, _) = current_rss_bytes();
399 eprintln!("[PURGE] mi_collect(true) called — current rss={:.2} MiB", rss as f64 / 1048576.0);
400 }
401 return;
402 }
403 #[cfg(feature = "allocator_jemalloc")]
404 {
405 // Purge all arenas. `arena.<i>.purge` with i = MALLCTL_ARENAS_ALL.
406 unsafe {
407 let _ = tikv_jemalloc_sys::mallctl(
408 b"arena.4096.purge\0".as_ptr() as *const _,
409 core::ptr::null_mut(),
410 core::ptr::null_mut(),
411 core::ptr::null_mut(),
412 0,
413 );
414 }
415 return;
416 }
417 #[cfg(all(target_os = "macos", not(miri), not(any(feature = "allocator_mimalloc", feature = "allocator_jemalloc"))))]
418 {
419 extern "C" {
420 fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize;
421 }
422 unsafe {
423 malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
424 }
425 }
426}
427
428/// Sample the process's "real" memory footprint (not peak).
429/// Returns (footprint_bytes, virtual_bytes). On macOS this is
430/// `phys_footprint` from `TASK_VM_INFO` — matches Activity Monitor
431/// "Memory" and `vmmap`'s "Physical footprint" line, and excludes
432/// shared library text pages that would otherwise inflate RSS
433/// without costing the process anything uniquely. On Linux this
434/// falls back to `/proc/self/statm` resident size (no direct
435/// equivalent; the shared-lib inflation is much smaller there).
436/// More useful than `getrusage.ru_maxrss` which only moves upward.
437#[cfg(feature = "probe")]
438pub fn current_rss_bytes() -> (u64, u64) {
439 // Miri cannot call the mach `task_info` foreign function; memory profiling
440 // is meaningless under Miri anyway, so report zero.
441 #[cfg(miri)]
442 return (0, 0);
443 #[cfg(all(target_os = "macos", not(miri)))]
444 {
445 // Prefer phys_footprint (TASK_VM_INFO). Fall back to
446 // resident_size (MACH_TASK_BASIC_INFO) if the bigger struct
447 // isn't populated for some reason.
448 let pf = phys_footprint_bytes();
449 #[repr(C)]
450 struct MachTaskBasicInfo {
451 virtual_size: u64,
452 resident_size: u64,
453 resident_size_max: u64,
454 user_time: [u32; 2],
455 system_time: [u32; 2],
456 policy: i32,
457 suspend_count: i32,
458 }
459 const MACH_TASK_BASIC_INFO: u32 = 20;
460 extern "C" {
461 fn mach_task_self() -> u32;
462 fn task_info(
463 target: u32, flavor: u32,
464 info: *mut core::ffi::c_void, count: *mut u32,
465 ) -> i32;
466 }
467 unsafe {
468 let mut info: MachTaskBasicInfo = core::mem::zeroed();
469 let mut count = (core::mem::size_of::<MachTaskBasicInfo>() / 4) as u32;
470 let kr = task_info(
471 mach_task_self(),
472 MACH_TASK_BASIC_INFO,
473 &mut info as *mut _ as *mut core::ffi::c_void,
474 &mut count,
475 );
476 if kr == 0 {
477 let rss = if pf != 0 { pf } else { info.resident_size };
478 (rss, info.virtual_size)
479 } else {
480 (pf, 0)
481 }
482 }
483 }
484 #[cfg(not(target_os = "macos"))]
485 { (0, 0) }
486}
487
488/// Heap bytes currently held by the libc allocator (`mstats.bytes_used`).
489///
490/// Unlike RSS, this is what *Rust* allocations plus anything else going
491/// through the default malloc zone is actually holding — mmap regions
492/// for thread stacks, GL buffers, file-mapped fonts, etc. are NOT counted.
493/// A leak that shows up here points to a genuine heap retention (an Arc
494/// chain never dropped, a Vec never shrunk, a `Box<T>` forgotten).
495/// Returns 0 on non-macOS.
496#[cfg(feature = "probe")]
497pub fn malloc_heap_bytes() -> u64 {
498 #[cfg(target_os = "macos")]
499 {
500 #[repr(C)]
501 struct Mstats {
502 bytes_total: usize,
503 chunks_used: usize,
504 bytes_used: usize,
505 chunks_free: usize,
506 bytes_free: usize,
507 }
508 extern "C" {
509 fn mstats() -> Mstats;
510 }
511 unsafe { mstats().bytes_used as u64 }
512 }
513 #[cfg(not(target_os = "macos"))]
514 { 0 }
515}
516
517/// Sample the Mach `phys_footprint` — the memory metric Activity
518/// Monitor and `vmmap`'s "Physical footprint" line display. Unlike
519/// `resident_size`, this excludes shared library text pages and
520/// other kernel-mapped regions that inflate the traditional RSS
521/// number without actually costing the process anything. For a
522/// short-lived headless render this is a much more honest figure:
523/// on a ~20 MiB ru_maxrss run, phys_footprint is typically ~8 MiB.
524/// Returns 0 on non-macOS or if the Mach call fails.
525///
526/// There's no direct "peak phys_footprint" field; track the max
527/// across calls in application code if you need it.
528#[cfg(feature = "probe")]
529pub fn phys_footprint_bytes() -> u64 {
530 // Miri cannot call the mach `task_info` foreign function.
531 #[cfg(miri)]
532 return 0;
533 #[cfg(all(target_os = "macos", not(miri)))]
534 {
535 // TASK_VM_INFO = 22; the struct is large (~88 u32 counts ≈ 352 B)
536 // and phys_footprint lives near the end, so we have to read the
537 // whole thing. Layout is from osfmk/mach/task_info.h.
538 #[repr(C)]
539 struct TaskVmInfo {
540 virtual_size: u64,
541 region_count: u32,
542 page_size: u32,
543 resident_size: u64,
544 resident_size_peak: u64,
545 device: u64,
546 device_peak: u64,
547 internal: u64,
548 internal_peak: u64,
549 external: u64,
550 external_peak: u64,
551 reusable: u64,
552 reusable_peak: u64,
553 purgeable_volatile_pmap: u64,
554 purgeable_volatile_resident: u64,
555 purgeable_volatile_virtual: u64,
556 compressed: u64,
557 compressed_peak: u64,
558 compressed_lifetime: u64,
559 phys_footprint: u64,
560 // there are more fields after this, but we don't need them
561 _rest: [u64; 12],
562 }
563 const TASK_VM_INFO: u32 = 22;
564 extern "C" {
565 fn mach_task_self() -> u32;
566 fn task_info(
567 target: u32, flavor: u32,
568 info: *mut core::ffi::c_void, count: *mut u32,
569 ) -> i32;
570 }
571 unsafe {
572 let mut info: TaskVmInfo = core::mem::zeroed();
573 let mut count = (core::mem::size_of::<TaskVmInfo>() / 4) as u32;
574 let kr = task_info(
575 mach_task_self(),
576 TASK_VM_INFO,
577 &mut info as *mut _ as *mut core::ffi::c_void,
578 &mut count,
579 );
580 if kr == 0 { info.phys_footprint } else { 0 }
581 }
582 }
583 #[cfg(not(target_os = "macos"))]
584 { 0 }
585}
586
587/// Background sampler for peak phys_footprint. Spawns a thread that
588/// polls `phys_footprint_bytes()` every ~2 ms and updates a shared
589/// atomic. The kernel does not expose a direct "peak phys_footprint"
590/// — unlike `resident_size_peak` in TASK_VM_INFO — so polling is
591/// the only way to catch mid-phase transients that are MADV_FREE'd
592/// before the next explicit sample point.
593///
594/// Not started by default; call `start_peak_sampler()` once at
595/// process init if you want peak tracking. Overhead is negligible
596/// (~1-5 µs per poll on macOS, 500 Hz → <0.25% CPU of one core).
597/// `peak_phys_footprint_seen()` reads the current high-water mark.
598#[cfg(feature = "probe")]
599pub fn start_peak_sampler() {
600 #[cfg(target_os = "macos")]
601 {
602 use std::sync::atomic::Ordering;
603 // Idempotent — only spawns once.
604 static STARTED: std::sync::atomic::AtomicBool =
605 std::sync::atomic::AtomicBool::new(false);
606 if STARTED.swap(true, Ordering::AcqRel) {
607 return;
608 }
609 std::thread::Builder::new()
610 .name("azul-peak-sampler".to_string())
611 .spawn(|| loop {
612 let now = phys_footprint_bytes();
613 let prev = PEAK_PHYS_FOOTPRINT.load(Ordering::Relaxed);
614 if now > prev {
615 PEAK_PHYS_FOOTPRINT.store(now, Ordering::Relaxed);
616 }
617 std::thread::sleep(std::time::Duration::from_micros(250));
618 })
619 .ok();
620 }
621}
622
623#[cfg(feature = "probe")]
624static PEAK_PHYS_FOOTPRINT: std::sync::atomic::AtomicU64 =
625 std::sync::atomic::AtomicU64::new(0);
626
627/// Read the peak `phys_footprint` seen by the background sampler.
628/// Returns 0 if `start_peak_sampler` was never called.
629#[cfg(feature = "probe")]
630pub fn peak_phys_footprint_seen() -> u64 {
631 PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed)
632}
633
634/// Reset the global peak high-water mark to the current phys_footprint.
635/// Paired with `peak_phys_footprint_seen()` so a caller can record
636/// "peak during phase X" — call `reset_peak()` at phase entry, then
637/// `peak_phys_footprint_seen()` at phase exit. The 500 Hz background
638/// sampler runs continuously either way.
639#[cfg(feature = "probe")]
640pub fn reset_peak() {
641 let now = phys_footprint_bytes();
642 PEAK_PHYS_FOOTPRINT.store(now, std::sync::atomic::Ordering::Relaxed);
643}
644
645/// Record a phase's peak footprint into the probe event stream.
646/// Call at phase exit after `reset_peak()` at phase entry. Emits an
647/// RSS-kind event with `bytes = peak seen during phase`.
648#[cfg(feature = "probe")]
649#[inline]
650pub fn sample_phase_peak(label: &'static str) {
651 let peak = PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed);
652 Probe::sample_rss(label, peak);
653}
654
655#[cfg(not(feature = "probe"))]
656#[inline]
657pub const fn reset_peak() {}
658
659#[cfg(not(feature = "probe"))]
660#[inline]
661pub const fn sample_phase_peak(_label: &'static str) {}
662
663#[cfg(not(feature = "probe"))]
664#[inline]
665#[must_use] pub const fn malloc_heap_bytes() -> u64 { 0 }
666
667/// Emit one `{"ev":"phase","label":L,"heap":N,"call":C}` line to the
668/// JSONL file named by `AZ_PROFILE_OUT=<path>`. Only fires when
669/// `AZ_PROFILE=heap,jsonl` is set *and* the path is given.
670///
671/// Each call auto-increments a monotonic `call` id so downstream
672/// analyzers can group phases belonging to a single `regenerate_layout`
673/// invocation.
674///
675/// `label` convention: `start` at function entry; `<step>` after each
676/// phase completes; `end` at function exit. Heap Δ between adjacent
677/// labels within the same call-id is the bytes retained by that phase.
678///
679/// Zero overhead when flags aren't set (two atomic loads). Zero overhead
680/// when the `probe` feature is off (no-op stub).
681#[cfg(feature = "probe")]
682pub fn emit_phase_heap(label: &str) {
683 use std::io::Write;
684 if !heap_jsonl_enabled() { return; }
685 let Some(p) = azul_core::profile::out_path() else { return };
686 static CALL_ID: std::sync::atomic::AtomicU64 =
687 std::sync::atomic::AtomicU64::new(0);
688 // Auto-increment on every "start" label; "end" and intermediates reuse
689 // the current id so all phases in one regenerate_layout invocation share
690 // a call number.
691 static CURRENT_CALL: std::sync::atomic::AtomicU64 =
692 std::sync::atomic::AtomicU64::new(0);
693 let call_id = if label == "start" {
694 let next = CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
695 CURRENT_CALL.store(next, std::sync::atomic::Ordering::Relaxed);
696 next
697 } else {
698 CURRENT_CALL.load(std::sync::atomic::Ordering::Relaxed)
699 };
700 let heap = malloc_heap_bytes();
701 if let Ok(mut f) = std::fs::OpenOptions::new()
702 .create(true)
703 .append(true)
704 .open(p)
705 {
706 let _ = writeln!(
707 f,
708 r#"{{"ev":"phase","call":{},"label":"{}","heap":{}}}"#,
709 call_id, label, heap
710 );
711 }
712}
713
714#[cfg(not(feature = "probe"))]
715#[inline]
716pub const fn emit_phase_heap(_label: &str) {}
717
718/// Like [`emit_phase_heap`] but attaches a numeric payload (e.g., a cache
719/// size) to the JSONL record under the `"extra"` field.
720///
721/// Gated behind `AZ_PROFILE=heap,jsonl,detail` — the `detail` token opts
722/// in to fine-grained probes that produce extra per-step records (one
723/// per intermediate step inside a phase). Without `detail`, only the
724/// coarser phase probes from [`emit_phase_heap`] fire.
725#[cfg(feature = "probe")]
726pub fn emit_phase_heap_extra(label: &str, extra: u64) {
727 use std::io::Write;
728 if !heap_jsonl_enabled() { return; }
729 if !azul_core::profile::detail_enabled() { return; }
730 let Some(p) = azul_core::profile::out_path() else { return };
731 let heap = malloc_heap_bytes();
732 if let Ok(mut f) = std::fs::OpenOptions::new()
733 .create(true)
734 .append(true)
735 .open(p)
736 {
737 let _ = writeln!(
738 f,
739 r#"{{"ev":"phase","call":0,"label":"{}","heap":{},"extra":{}}}"#,
740 label, heap, extra
741 );
742 }
743}
744
745#[cfg(not(feature = "probe"))]
746#[inline]
747pub const fn emit_phase_heap_extra(_label: &str, _extra: u64) {}
748
749/// Both `heap` and `jsonl` tokens active in `AZ_PROFILE` — the combination
750/// that enables JSONL heap-probe emission. Either alone is a no-op.
751#[cfg(feature = "probe")]
752#[inline]
753fn heap_jsonl_enabled() -> bool {
754 let f = azul_core::profile::flags();
755 f.heap && f.jsonl
756}
757
758/// Returns true iff `AZ_PROFILE=detail` is active. Kept as a public
759/// re-export so downstream crates can write `azul_layout::probe::detail_enabled()`
760/// without pulling in `azul_core::profile` directly.
761#[cfg(feature = "probe")]
762#[inline]
763pub fn detail_enabled() -> bool {
764 azul_core::profile::detail_enabled()
765}
766
767#[cfg(not(feature = "probe"))]
768#[inline]
769#[must_use] pub const fn detail_enabled() -> bool { false }