From d20213d989e915d75cbd2ca8b466b430a29bfde7 Mon Sep 17 00:00:00 2001 From: Cam McHenry Date: Thu, 14 Aug 2025 10:01:59 -0400 Subject: [PATCH 1/2] feat(allocs): add callsite tracking for allocations --- .cargo/config.toml | 2 +- Cargo.lock | 1 + Cargo.toml | 6 + tasks/track_memory_allocations/Cargo.toml | 1 + tasks/track_memory_allocations/README.md | 24 ++ tasks/track_memory_allocations/src/lib.rs | 287 +++++++++++++++++++++- 6 files changed, 317 insertions(+), 4 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 5a6b7b04ba9a7..583ec2210ec11 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,7 +6,7 @@ codecov = "llvm-cov --workspace --ignore-filename-regex tasks" coverage = "run -p oxc_coverage --profile coverage --" benchmark = "bench -p oxc_benchmark" minsize = "run -p oxc_minsize --profile coverage --" -allocs = "run -p oxc_track_memory_allocations --profile coverage --" +allocs = "run -p oxc_track_memory_allocations --profile coverage-with-debug --" rule = "run -p rulegen" # Build oxlint in release mode diff --git a/Cargo.lock b/Cargo.lock index 8c978127114f1..cf04d4dc68039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2379,6 +2379,7 @@ dependencies = [ name = "oxc_track_memory_allocations" version = "0.0.0" dependencies = [ + "backtrace", "humansize", "mimalloc-safe", "oxc_allocator", diff --git a/Cargo.toml b/Cargo.toml index e2eb8d9b031c1..a5c390410e795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -284,6 +284,12 @@ lto = "thin" # Faster compile time with thin LTO debug-assertions = true # Make sure `debug_assert!`s pass overflow-checks = true # Catch arithmetic overflow errors +# Same as `coverage`, but with debug symbols included for backtraces. +[profile.coverage-with-debug] +inherits = "coverage" +strip = false # Keep debug information in binary +debug = true # Include maximum amount of debug information + # Profile for linting with release mode-like settings. # Catches lint errors which only appear in release mode. # `cargo lint --profile dev-no-debug-assertions` is about 35% faster than `cargo lint --release`. diff --git a/tasks/track_memory_allocations/Cargo.toml b/tasks/track_memory_allocations/Cargo.toml index e6817e269c32b..fe8f29446ddd1 100644 --- a/tasks/track_memory_allocations/Cargo.toml +++ b/tasks/track_memory_allocations/Cargo.toml @@ -25,6 +25,7 @@ oxc_tasks_common = { workspace = true } humansize = { workspace = true } mimalloc-safe = { workspace = true } +backtrace = "0.3" [features] # Sentinel flag that should only get enabled if we're running under `--all-features` diff --git a/tasks/track_memory_allocations/README.md b/tasks/track_memory_allocations/README.md index 211086a48e3f5..cef3c629535d3 100644 --- a/tasks/track_memory_allocations/README.md +++ b/tasks/track_memory_allocations/README.md @@ -1,3 +1,27 @@ # Oxc allocations stats This task keeps track of the number of system allocations as well as arena allocations and the total number of bytes allocated. This is used for monitoring possible regressions in allocations and improvements in memory usage. + +## Callsite aggregation (optional) + +You can also aggregate where allocations occur by source location, similar in spirit to dhat-rs. This is disabled by default because collecting backtraces for every allocation is expensive. + +- Enable tracking: set `OXC_ALLOC_SITES=1` +- Optional sampling to reduce overhead: set `OXC_ALLOC_SAMPLE=N` to record 1 in `N` allocations (default `1000`). + +Example: + +``` +OXC_ALLOC_SITES=1 OXC_ALLOC_SAMPLE=100 cargo run -p oxc_track_memory_allocations +``` + +At the end of the run, you'll see a "Top allocation sites" section like: + +``` +Top allocation sites (sampled 1 in 100) +Location | Allocations +---------------------------------------------------- +crates/oxc_parser/src/lexer.rs:123 | 152 +crates/oxc_semantic/src/builder.rs:45 | 101 +... +``` diff --git a/tasks/track_memory_allocations/src/lib.rs b/tasks/track_memory_allocations/src/lib.rs index 029038dbb0c4c..7e90f176c60bd 100644 --- a/tasks/track_memory_allocations/src/lib.rs +++ b/tasks/track_memory_allocations/src/lib.rs @@ -15,7 +15,16 @@ use oxc_semantic::SemanticBuilder; use oxc_tasks_common::{TestFile, TestFiles, project_root}; use std::alloc::{GlobalAlloc, Layout}; +use std::cell::Cell; +use std::cmp::Reverse; +use std::collections::BinaryHeap; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +use std::sync::{Mutex, OnceLock}; + +use backtrace::Backtrace; +use std::env; #[global_allocator] static GLOBAL: TrackedAllocator = TrackedAllocator; @@ -29,10 +38,142 @@ struct TrackedAllocator; // allocations, so just tracking the number of allocations is sufficient for our purposes. static NUM_ALLOC: AtomicUsize = AtomicUsize::new(0); static NUM_REALLOC: AtomicUsize = AtomicUsize::new(0); +static ALLOC_SEQ: AtomicUsize = AtomicUsize::new(0); + +// Re-entrancy guard to avoid tracking allocations that occur while we're +// doing the tracking work itself (e.g., backtrace symbolization, map updates). +thread_local! { + static IN_TRACKING: Cell = Cell::new(false); +} + +// Global aggregation of allocation sites: "path:line" -> count +#[derive(Default, Clone, Copy)] +struct SiteCounts { allocs: usize, reallocs: usize } + +static ALLOC_SITES: OnceLock>> = OnceLock::new(); +fn alloc_sites() -> &'static Mutex> { + ALLOC_SITES.get_or_init(|| Mutex::new(HashMap::new())) +} + +static PROJECT_ROOT: OnceLock = OnceLock::new(); +fn get_project_root() -> &'static Path { + PROJECT_ROOT.get_or_init(|| project_root()).as_path() +} + +// Whether we should collect callsite info; off by default to avoid massive slowdowns. +static TRACK_SITES: OnceLock = OnceLock::new(); +fn should_track_sites() -> bool { + *TRACK_SITES.get_or_init(|| match env::var("OXC_ALLOC_SITES") { + Ok(v) => matches!(v.as_str(), "1" | "true" | "yes" | "on"), + Err(_) => false, + }) +} + +// Sampling interval: record 1 in N allocations to reduce overhead. Default 1000. +static SAMPLE_N: OnceLock = OnceLock::new(); +fn sample_interval() -> usize { + *SAMPLE_N.get_or_init(|| match env::var("OXC_ALLOC_SAMPLE") { + Ok(v) => v.parse().ok().filter(|n: &usize| *n > 0).unwrap_or(1000), + Err(_) => 1000, + }) +} + +fn reset_site_counts() { + if let Ok(mut m) = alloc_sites().lock() { + m.clear(); + } +} + +enum AllocKind { Alloc, Realloc } + +fn record_allocation_site(kind: AllocKind) { + // Note: this function must be called with IN_TRACKING already set to true + // to ensure any allocations here don't get double-counted. We also keep + // the work minimal. + let mut bt = Backtrace::new_unresolved(); + bt.resolve(); + let mut key: Option = None; + let root = get_project_root(); + + // Resolve just enough frames to find the first oxc-repo file with a line. + 'outer: for frame in bt.frames() { + for symbol in frame.symbols() { + if let (Some(path), Some(lineno)) = (symbol.filename(), symbol.lineno()) { + // Prefer frames within the repository, and skip this task's own files. + if path.starts_with(root) + && !path.components().any(|c| c.as_os_str() == "track_memory_allocations") + { + // Make path relative to project root for readability. + let rel = path.strip_prefix(root).unwrap_or(path); + key = Some(format!("{}:{}", rel.display(), lineno)); + break 'outer; + } + } + } + } + + // Fallback: if we didn't find a repo frame, try any frame with file:line. + if key.is_none() { + 'outer2: for frame in bt.frames() { + for symbol in frame.symbols() { + if let (Some(path), Some(lineno)) = (symbol.filename(), symbol.lineno()) { + key = Some(format!("{}:{}", path.display(), lineno)); + break 'outer2; + } + } + } + } + + // Fallback: use function names when file:line isn't available (e.g., debug info stripped). + if key.is_none() { + // Prefer frames whose demangled name mentions oxc crates/modules. + let mut best_oxc: Option = None; + let mut best_nonstd: Option = None; + let is_skip = |s: &str| { + s.contains("track_memory_allocations") + || s.contains("record_allocation_site") + || s.contains("TrackedAllocator") + || s.contains("backtrace::") + || s.contains("::resolve") + || s.starts_with("std::") + || s.starts_with("core::") + || s.starts_with("alloc::") + || s.contains("mimalloc") + }; + + for frame in bt.frames() { + for symbol in frame.symbols() { + if let Some(name) = symbol.name() { + let s = format!("{:#}", name); + if is_skip(&s) { + continue; + } + if s.contains("oxc_") || s.contains("oxlint") || s.contains("oxc::") { + best_oxc.get_or_insert_with(|| s.clone()); + // Keep searching in case there is a better, deeper frame, but first match is fine. + } else { + best_nonstd.get_or_insert(s); + } + } + } + } + key = best_oxc.or(best_nonstd); + } + + let k = key.unwrap_or_else(|| "".to_string()); + if let Ok(mut m) = alloc_sites().lock() { + let entry = m.entry(k).or_default(); + match kind { + AllocKind::Alloc => entry.allocs = entry.allocs.saturating_add(1), + AllocKind::Realloc => entry.reallocs = entry.reallocs.saturating_add(1), + } + } +} fn reset_global_allocs() { NUM_ALLOC.store(0, SeqCst); NUM_REALLOC.store(0, SeqCst); + ALLOC_SEQ.store(0, SeqCst); } // SAFETY: Methods simply delegate to `MiMalloc` allocator to ensure that the allocator @@ -42,7 +183,23 @@ unsafe impl GlobalAlloc for TrackedAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { let ret = unsafe { MiMalloc.alloc(layout) }; if !ret.is_null() { - NUM_ALLOC.fetch_add(1, SeqCst); + IN_TRACKING.with(|f| { + if f.get() { + // Skip counting and callsite attribution for re-entrant allocations + // triggered by tracking itself. + } else { + f.set(true); + NUM_ALLOC.fetch_add(1, SeqCst); + if should_track_sites() { + let n = sample_interval(); + let seq = ALLOC_SEQ.fetch_add(1, SeqCst); + if seq % n == 0 { + record_allocation_site(AllocKind::Alloc); + } + } + f.set(false); + } + }); } ret } @@ -54,7 +211,22 @@ unsafe impl GlobalAlloc for TrackedAllocator { unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { let ret = unsafe { MiMalloc.alloc_zeroed(layout) }; if !ret.is_null() { - NUM_ALLOC.fetch_add(1, SeqCst); + IN_TRACKING.with(|f| { + if f.get() { + // see alloc() + } else { + f.set(true); + NUM_ALLOC.fetch_add(1, SeqCst); + if should_track_sites() { + let n = sample_interval(); + let seq = ALLOC_SEQ.fetch_add(1, SeqCst); + if seq % n == 0 { + record_allocation_site(AllocKind::Alloc); + } + } + f.set(false); + } + }); } ret } @@ -62,7 +234,22 @@ unsafe impl GlobalAlloc for TrackedAllocator { unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { let ret = unsafe { MiMalloc.realloc(ptr, layout, new_size) }; if !ret.is_null() { - NUM_REALLOC.fetch_add(1, SeqCst); + IN_TRACKING.with(|f| { + if f.get() { + // see alloc() + } else { + f.set(true); + NUM_REALLOC.fetch_add(1, SeqCst); + if should_track_sites() { + let n = sample_interval(); + let seq = ALLOC_SEQ.fetch_add(1, SeqCst); + if seq % n == 0 { + record_allocation_site(AllocKind::Realloc); + } + } + f.set(false); + } + }); } ret } @@ -133,6 +320,10 @@ pub fn run() -> Result<(), io::Error> { .build(&ret.program); } + // Reset counts post warm-up so only measured work below is captured. + reset_global_allocs(); + reset_site_counts(); + for file in files.files() { allocator.reset(); reset_global_allocs(); @@ -176,6 +367,12 @@ pub fn run() -> Result<(), io::Error> { snapshot.write_all(semantic_out.as_bytes())?; snapshot.flush()?; + if should_track_sites() { + // Print and snapshot top allocation sites + let sites_out = print_top_sites(1000); + println!("{sites_out}"); + } + Ok(()) } @@ -217,3 +414,87 @@ fn print_stats_table(stats: &[Stats]) -> String { out } + +fn print_top_sites(limit: usize) -> String { + // Build a top-k selection using a min-heap to avoid sorting the full map. + #[derive(Eq, PartialEq)] + struct KeyRef<'a> { total: usize, allocs: usize, reallocs: usize, key: &'a str } + impl<'a> Ord for KeyRef<'a> { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Min-heap via Reverse: smaller total first; for ties, larger key first + self.total.cmp(&other.total).then_with(|| other.key.cmp(self.key)) + } + } + impl<'a> PartialOrd for KeyRef<'a> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + let mut top: Vec<(String, SiteCounts)> = Vec::new(); + + IN_TRACKING.with(|flag| { + let prev = flag.replace(true); + if let Ok(m) = alloc_sites().lock() { + let mut heap: BinaryHeap>> = BinaryHeap::with_capacity(limit); + for (ks, counts) in m.iter() { + let total = counts.allocs.saturating_add(counts.reallocs); + if total == 0 { continue; } + let entry = KeyRef { total, allocs: counts.allocs, reallocs: counts.reallocs, key: ks.as_str() }; + if heap.len() < limit { + heap.push(Reverse(entry)); + } else if let Some(Reverse(worst)) = heap.peek() { + if entry.total > worst.total + || (entry.total == worst.total && entry.key < worst.key) + { + let _ = heap.pop(); + heap.push(Reverse(entry)); + } + } + } + top.reserve(heap.len()); + while let Some(Reverse(e)) = heap.pop() { + top.push((e.key.to_owned(), SiteCounts { allocs: e.allocs, reallocs: e.reallocs })); + } + } + flag.set(prev); + }); + + // Sort selection for display: total desc, key asc. + top.sort_unstable_by(|a, b| { + let at = a.1.allocs + a.1.reallocs; + let bt = b.1.allocs + b.1.reallocs; + bt.cmp(&at).then_with(|| a.0.cmp(&b.0)) + }); + + let mut out = String::new(); + let width_loc = top.iter().map(|(k, _)| k.len()).max().unwrap_or(10).max("Location".len()); + let width_cnt = 14; + let n = sample_interval(); + let _ = writeln!(out, "Top allocation sites (sampled 1 in {n})"); + writeln!( + out, + "{:width_loc$} | {:width_cnt$} | {:width_cnt$} | {:width_cnt$}", + "Location", + "Allocations", + "Reallocations", + "Total", + width_loc = width_loc, + width_cnt = width_cnt + ).unwrap(); + let dash_len = width_loc + 3 + (width_cnt + 3) * 3 - 3; + out.push_str(&"-".repeat(dash_len)); + out.push('\n'); + for (loc, counts) in top { + let total = counts.allocs + counts.reallocs; + let _ = writeln!(out, + "{:width_loc$} | {:width_cnt$} | {:width_cnt$} | {:width_cnt$}", + loc, + counts.allocs, + counts.reallocs, + total, + width_loc = width_loc, + width_cnt = width_cnt); + } + out +} From ceccb3b9d07ede3150542c99bdbbad9111f126c7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:03:02 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- tasks/track_memory_allocations/Cargo.toml | 2 +- tasks/track_memory_allocations/README.md | 4 +- tasks/track_memory_allocations/src/lib.rs | 49 ++++++++++++++++------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/tasks/track_memory_allocations/Cargo.toml b/tasks/track_memory_allocations/Cargo.toml index fe8f29446ddd1..2bf416915f65c 100644 --- a/tasks/track_memory_allocations/Cargo.toml +++ b/tasks/track_memory_allocations/Cargo.toml @@ -23,9 +23,9 @@ oxc_parser = { workspace = true } oxc_semantic = { workspace = true } oxc_tasks_common = { workspace = true } +backtrace = "0.3" humansize = { workspace = true } mimalloc-safe = { workspace = true } -backtrace = "0.3" [features] # Sentinel flag that should only get enabled if we're running under `--all-features` diff --git a/tasks/track_memory_allocations/README.md b/tasks/track_memory_allocations/README.md index cef3c629535d3..f73e280feb9d6 100644 --- a/tasks/track_memory_allocations/README.md +++ b/tasks/track_memory_allocations/README.md @@ -6,8 +6,8 @@ This task keeps track of the number of system allocations as well as arena alloc You can also aggregate where allocations occur by source location, similar in spirit to dhat-rs. This is disabled by default because collecting backtraces for every allocation is expensive. -- Enable tracking: set `OXC_ALLOC_SITES=1` -- Optional sampling to reduce overhead: set `OXC_ALLOC_SAMPLE=N` to record 1 in `N` allocations (default `1000`). +- Enable tracking: set `OXC_ALLOC_SITES=1` +- Optional sampling to reduce overhead: set `OXC_ALLOC_SAMPLE=N` to record 1 in `N` allocations (default `1000`). Example: diff --git a/tasks/track_memory_allocations/src/lib.rs b/tasks/track_memory_allocations/src/lib.rs index 7e90f176c60bd..bdacf1792984b 100644 --- a/tasks/track_memory_allocations/src/lib.rs +++ b/tasks/track_memory_allocations/src/lib.rs @@ -48,7 +48,10 @@ thread_local! { // Global aggregation of allocation sites: "path:line" -> count #[derive(Default, Clone, Copy)] -struct SiteCounts { allocs: usize, reallocs: usize } +struct SiteCounts { + allocs: usize, + reallocs: usize, +} static ALLOC_SITES: OnceLock>> = OnceLock::new(); fn alloc_sites() -> &'static Mutex> { @@ -84,7 +87,10 @@ fn reset_site_counts() { } } -enum AllocKind { Alloc, Realloc } +enum AllocKind { + Alloc, + Realloc, +} fn record_allocation_site(kind: AllocKind) { // Note: this function must be called with IN_TRACKING already set to true @@ -190,11 +196,11 @@ unsafe impl GlobalAlloc for TrackedAllocator { } else { f.set(true); NUM_ALLOC.fetch_add(1, SeqCst); - if should_track_sites() { + if should_track_sites() { let n = sample_interval(); let seq = ALLOC_SEQ.fetch_add(1, SeqCst); if seq % n == 0 { - record_allocation_site(AllocKind::Alloc); + record_allocation_site(AllocKind::Alloc); } } f.set(false); @@ -217,11 +223,11 @@ unsafe impl GlobalAlloc for TrackedAllocator { } else { f.set(true); NUM_ALLOC.fetch_add(1, SeqCst); - if should_track_sites() { + if should_track_sites() { let n = sample_interval(); let seq = ALLOC_SEQ.fetch_add(1, SeqCst); if seq % n == 0 { - record_allocation_site(AllocKind::Alloc); + record_allocation_site(AllocKind::Alloc); } } f.set(false); @@ -240,11 +246,11 @@ unsafe impl GlobalAlloc for TrackedAllocator { } else { f.set(true); NUM_REALLOC.fetch_add(1, SeqCst); - if should_track_sites() { + if should_track_sites() { let n = sample_interval(); let seq = ALLOC_SEQ.fetch_add(1, SeqCst); if seq % n == 0 { - record_allocation_site(AllocKind::Realloc); + record_allocation_site(AllocKind::Realloc); } } f.set(false); @@ -418,7 +424,12 @@ fn print_stats_table(stats: &[Stats]) -> String { fn print_top_sites(limit: usize) -> String { // Build a top-k selection using a min-heap to avoid sorting the full map. #[derive(Eq, PartialEq)] - struct KeyRef<'a> { total: usize, allocs: usize, reallocs: usize, key: &'a str } + struct KeyRef<'a> { + total: usize, + allocs: usize, + reallocs: usize, + key: &'a str, + } impl<'a> Ord for KeyRef<'a> { fn cmp(&self, other: &Self) -> std::cmp::Ordering { // Min-heap via Reverse: smaller total first; for ties, larger key first @@ -439,8 +450,15 @@ fn print_top_sites(limit: usize) -> String { let mut heap: BinaryHeap>> = BinaryHeap::with_capacity(limit); for (ks, counts) in m.iter() { let total = counts.allocs.saturating_add(counts.reallocs); - if total == 0 { continue; } - let entry = KeyRef { total, allocs: counts.allocs, reallocs: counts.reallocs, key: ks.as_str() }; + if total == 0 { + continue; + } + let entry = KeyRef { + total, + allocs: counts.allocs, + reallocs: counts.reallocs, + key: ks.as_str(), + }; if heap.len() < limit { heap.push(Reverse(entry)); } else if let Some(Reverse(worst)) = heap.peek() { @@ -481,20 +499,23 @@ fn print_top_sites(limit: usize) -> String { "Total", width_loc = width_loc, width_cnt = width_cnt - ).unwrap(); + ) + .unwrap(); let dash_len = width_loc + 3 + (width_cnt + 3) * 3 - 3; out.push_str(&"-".repeat(dash_len)); out.push('\n'); for (loc, counts) in top { let total = counts.allocs + counts.reallocs; - let _ = writeln!(out, + let _ = writeln!( + out, "{:width_loc$} | {:width_cnt$} | {:width_cnt$} | {:width_cnt$}", loc, counts.allocs, counts.reallocs, total, width_loc = width_loc, - width_cnt = width_cnt); + width_cnt = width_cnt + ); } out }