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.
This commit is contained in:
Cam McHenry authored and GitHub committed 2026-10-30 12:26:30 -04:00
1 parent e3f90b1f80
commit 004647a4fa
8 files changed
+145 -1

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Rust
target/
**/*.rs.bk
/tasks/track_memory_allocations/allocs_linter.yaml
# Wasm
**/*.wasm
Generated
+2
View File
@@ -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",
]
+1 -1
View File
@@ -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 }
+28
View File
@@ -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<Self>, 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::<false>(&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::<false>(node, &ctx, None);
}
}
}
}
/// Creates a new [`LintContext`] for testing purposes only.
#[cfg(test)]
pub(crate) fn spawn_for_test(self: Rc<Self>) -> LintContext<'a> {
@@ -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 = []
+29
View File
@@ -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):
@@ -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();
@@ -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)
}