From 004647a4fa8cbd2a3a2c633866fd867ef9d229f9 Mon Sep 17 00:00:00 2001 From: Cam McHenry Date: Fri, 30 Oct 2026 12:26:30 -0400 Subject: [PATCH] feat(track_memory_allocs): add allocation tracking for linter rules (#28470) As an alternative to https://github.com/oxc-project/oxc/pull/27477, this requires minimal changes in the linter in order to add allocation tracking for individual rules. We run each rule individually and then compare the diff in heap size before and after as well as the number of system and arena allocations. Additionally, this runs as a local-only opt-in feature so that we don't check this in CI automatically, since linter allocations are likely quite noisy. --- .gitignore | 1 + Cargo.lock | 2 + crates/oxc_linter/Cargo.toml | 2 +- crates/oxc_linter/src/context/host.rs | 28 ++++++++ tasks/track_memory_allocations/Cargo.toml | 3 + tasks/track_memory_allocations/README.md | 29 ++++++++ tasks/track_memory_allocations/src/lib.rs | 8 +++ tasks/track_memory_allocations/src/linter.rs | 73 ++++++++++++++++++++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tasks/track_memory_allocations/src/linter.rs diff --git a/.gitignore b/.gitignore index 25a10b9deb..cca1f312af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Rust target/ **/*.rs.bk +/tasks/track_memory_allocations/allocs_linter.yaml # Wasm **/*.wasm diff --git a/Cargo.lock b/Cargo.lock index facb3ab9d3..defd5ecde1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2763,11 +2763,13 @@ dependencies = [ "mimalloc-safe", "oxc_allocator", "oxc_formatter", + "oxc_linter", "oxc_minifier", "oxc_parser", "oxc_semantic", "oxc_tasks_common", "oxc_transformer", + "rustc-hash", "saphyr", ] diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index 0250a9288e..b5e3068253 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -17,6 +17,7 @@ description.workspace = true default = [] ruledocs = ["oxc_macros/ruledocs"] # Enables the `ruledocs` feature for conditional compilation force_test_reporter = [] +track_allocations = [] [lints] workspace = true @@ -81,4 +82,3 @@ url = { workspace = true } [dev-dependencies] insta = { workspace = true } markdown = { workspace = true } - diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index 4596bce29b..5cad0abb8d 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -516,6 +516,34 @@ impl<'a> ContextHost<'a> { } } + /// Run one native rule's callbacks for allocation profiling, without linter setup or teardown. + /// + /// Uses the same eligibility and AST type filters as the regular rule runner. + #[cfg(feature = "track_allocations")] + pub fn run_rule_for_allocations(self: Rc, rule: &RuleEnum, severity: AllowWarnDeny) { + let run_info = rule.run_info(); + let ast_types = rule.types_info(); + if rule.is_tsgolint_rule() + || (run_info == crate::RuleRunFunctionsImplemented::Run + && ast_types.is_some_and(|types| !self.semantic().nodes().contains_any(types))) + || !rule.should_run(&self) + { + return; + } + + let ctx = self.spawn(rule, severity); + if run_info.is_run_once_implemented() { + rule.run_once::(&ctx, None); + } + if run_info.is_run_implemented() { + for node in ctx.nodes() { + if ast_types.is_none_or(|types| types.has(node.kind().ty())) { + rule.run::(node, &ctx, None); + } + } + } + } + /// Creates a new [`LintContext`] for testing purposes only. #[cfg(test)] pub(crate) fn spawn_for_test(self: Rc) -> LintContext<'a> { diff --git a/tasks/track_memory_allocations/Cargo.toml b/tasks/track_memory_allocations/Cargo.toml index 9c5a9b79b0..3c409ad05e 100644 --- a/tasks/track_memory_allocations/Cargo.toml +++ b/tasks/track_memory_allocations/Cargo.toml @@ -19,6 +19,7 @@ doctest = false [dependencies] oxc_allocator = { workspace = true, features = ["track_allocations"] } oxc_formatter = { workspace = true } +oxc_linter = { workspace = true, optional = true, features = ["track_allocations"] } oxc_minifier = { workspace = true } oxc_parser = { workspace = true } oxc_semantic = { workspace = true } @@ -27,8 +28,10 @@ oxc_transformer = { workspace = true } humansize = { workspace = true } mimalloc-safe = { workspace = true } +rustc-hash = { workspace = true, optional = true } saphyr = { workspace = true } [features] +linter = ["dep:oxc_linter", "dep:rustc-hash"] # Sentinel flag that should only get enabled if we're running under `--all-features` is_all_features = [] diff --git a/tasks/track_memory_allocations/README.md b/tasks/track_memory_allocations/README.md index 44a17ac571..f0c559cb05 100644 --- a/tasks/track_memory_allocations/README.md +++ b/tasks/track_memory_allocations/README.md @@ -4,6 +4,35 @@ This task keeps track of the number of system allocations as well as arena alloc Update the snapshots with `cargo allocs`. +## Optional per-rule linter tracking + +Run `cargo run -p oxc_track_memory_allocations --profile coverage --features linter` +to write only `tasks/track_memory_allocations/allocs_linter.yaml`, grouped by file +and plugin/rule name. This is opt-in: normal `cargo allocs` runs and CI do not run +it, including CI builds using `--all-features`. + +Linter-enabled builds do not measure or rewrite the normal stage snapshots: +the linter enables additional semantic-analysis features that change their +allocation counts. Use `cargo allocs` without the feature for those snapshots. + +The generated report is gitignored. Within each file, rules are sorted by +`sys alloc bytes` descending, then `sys allocs` descending, then rule name. +Values reflect the current run without snapshot tolerances. File size is omitted +from the per-rule metrics. + +Only native rule callbacks (`should_run`, `run_once`, and `run`) are measured, +with the regular eligibility and AST type filters. Parsing, semantic analysis, +configuration, context construction, and context teardown are excluded. Type-aware +rules implemented by tsgolint are omitted. Skipped native rules have zero activity +unless their `should_run` callback allocates. + +Rules run individually in rule-ID order, rather than interleaving their node +callbacks as the production runner does. They share a fresh per-file context. +Diagnostic storage growth is charged to the rule that triggers it, and lazy shared +work (such as React Compiler analysis) is charged to its first consumer. Diagnostics +remain alive until all rules finish. For these measurements, `arena size` is the +arena growth during that rule rather than the cumulative arena size. + ## Metrics Recorded per file and per stage (parser, semantic, transformer, minifier, formatter): diff --git a/tasks/track_memory_allocations/src/lib.rs b/tasks/track_memory_allocations/src/lib.rs index 27833ee146..902f34cac8 100644 --- a/tasks/track_memory_allocations/src/lib.rs +++ b/tasks/track_memory_allocations/src/lib.rs @@ -15,6 +15,9 @@ use oxc_transformer::{TransformOptions, Transformer}; use std::alloc::{GlobalAlloc, Layout}; use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +#[cfg(feature = "linter")] +mod linter; + #[global_allocator] static GLOBAL: TrackedAllocator = TrackedAllocator; @@ -300,6 +303,11 @@ fn test() { pub fn run() -> Result<(), io::Error> { let files = TestFiles::complicated(); + #[cfg(feature = "linter")] + if !cfg!(feature = "is_all_features") { + return linter::run(&files); + } + let mut parser_entries = Vec::new(); let mut semantic_entries = Vec::new(); let mut transformer_entries = Vec::new(); diff --git a/tasks/track_memory_allocations/src/linter.rs b/tasks/track_memory_allocations/src/linter.rs new file mode 100644 index 0000000000..a9da0c8fea --- /dev/null +++ b/tasks/track_memory_allocations/src/linter.rs @@ -0,0 +1,73 @@ +use std::{fmt::Write as _, fs, io, path::Path, rc::Rc, sync::Arc}; + +use oxc_allocator::Allocator; +use oxc_linter::{ + ConfigStore, ConfigStoreBuilder, ContextSubHost, ContextSubHostOptions, ExternalPluginStore, + LintOptions, ModuleRecord, rules::ContextHost, +}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_tasks_common::{TestFiles, project_root}; +use rustc_hash::FxHashMap; + +use super::{record_stats_in, render_file_stats}; + +pub fn run(files: &TestFiles) -> io::Result<()> { + let mut external_plugin_store = ExternalPluginStore::default(); + let config = ConfigStoreBuilder::all().build(&mut external_plugin_store).unwrap(); + let config_store = ConfigStore::new(config, FxHashMap::default(), external_plugin_store); + let snapshot_path = project_root().join("tasks/track_memory_allocations/allocs_linter.yaml"); + let mut out = String::new(); + + for file in files.files() { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &file.source_text, file.source_type).parse(); + assert!(parsed.diagnostics.is_empty()); + let path = Path::new(&file.file_name); + let semantic = SemanticBuilder::new_linter().build(&parsed.program).semantic; + let module_record = Arc::new(ModuleRecord::new(path, &parsed.module_record, &semantic)); + let state = config_store.resolve(path); + let host = Rc::new(ContextHost::new( + path, + vec![ContextSubHost::new(semantic, module_record, 0, ContextSubHostOptions::default())], + &allocator, + LintOptions::default(), + state.config, + )); + + let mut entries = Vec::new(); + for (rule, severity) in state.rules.iter().filter(|(rule, _)| !rule.is_tsgolint_rule()) { + let rule_name = format!("{}/{}", rule.plugin_name(), rule.name()); + let arena_before = allocator.used_bytes(); + let ((), mut stats) = record_stats_in(&allocator, || { + Rc::clone(&host).run_rule_for_allocations(rule, *severity); + }); + stats.arena_used_bytes = allocator.used_bytes().saturating_sub(arena_before); + entries.push((rule_name, stats)); + } + + entries.sort_unstable_by(|(left_name, left), (right_name, right)| { + right + .counters + .heap + .alloc_bytes + .cmp(&left.counters.heap.alloc_bytes) + .then_with(|| right.counters.heap.allocs.cmp(&left.counters.heap.allocs)) + .then_with(|| left_name.cmp(right_name)) + }); + + writeln!(out, "{}:", file.file_name).unwrap(); + for (rule_name, stats) in entries { + let mut metrics = stats.metrics(file.source_text.len()); + metrics.retain(|metric| metric.label != "file size"); + let mut rule_out = String::new(); + render_file_stats(&mut rule_out, &rule_name, &metrics, None); + for line in rule_out.lines() { + writeln!(out, " {line}").unwrap(); + } + } + out.push('\n'); + } + + fs::write(snapshot_path, out) +}