feat(linter): per-rule timing support#19689
Conversation
Adds `--timing` flag (and `TIMING` env var) to oxlint that shows a
per-rule breakdown of execution time at the end of a lint run.
## What's measured
**Built-in Rust rules** — each rule call is wrapped with `Instant::now()`
via a `timed_call!` macro; durations are accumulated in a per-file
`FxHashMap` and merged into a shared `TimingStore` once per file to
avoid per-call lock contention.
**Type-aware rules (tsgolint)** — when `TIMING` is set the tsgolint
subprocess wraps each listener with a nanosecond timer and sends a
binary-framed timing message after all diagnostics. The Rust side
merges these into `TimingStore`. The wall-clock time minus the sum
of per-rule times is recorded as "Type-aware linting setup" overhead
(captures TypeScript program-loading cost).
**JS plugin rules** — the JS runtime (`src-js/plugins/lint.ts`) times
`create`, `before`/`after` hooks, and visitor callbacks when `TIMING`
is set, returning them as `timings: number[]` alongside diagnostics.
The Rust NAPI bridge measures the full `lint_file` call; subtracting
JS rule times gives "JS plugin bridge" overhead.
## Output
```
Rule | Time (ms) | Relative
--------------------------------|----------:|---------:
no-floating-promises | 58.20 | 73.7%
no-misused-promises | 3.10 | 3.9%
...
Overhead | Time (ms)
--------------------------------|----------:
Type-aware linting setup | 120.40
JS plugin bridge | 3.67
```
`Relative` is expressed as a percentage of wall-clock run time.
## Protocol change
`LintFileReturnValue::Success` is changed from a tuple variant to a
struct variant (`{ diagnostics, timings }`) so the JS side can return
optional per-rule timing data without a separate call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive per-rule timing support to oxlint, allowing developers to profile rule performance across all three rule types: built-in Rust rules, type-aware rules (tsgolint), and JS plugin rules. The implementation uses a --timing flag (or TIMING=1 environment variable) to enable instrumentation.
Changes:
- Introduces a
TimingStorefor accumulating per-rule timing data with minimal lock contention - Instruments built-in Rust rules using a
timed_call!macro that conditionally times rule execution with zero overhead when disabled - Integrates timing data from tsgolint subprocess via a new binary-framed timing message protocol
- Captures JS plugin timing by wrapping visitor callbacks and tracking execution time
- Outputs timing data as formatted tables showing top 10 rules and overhead breakdown
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/oxc_linter/src/timing.rs | New module defining TimingStore for thread-safe accumulation of per-rule timing data and overhead tracking |
| crates/oxc_linter/src/tsgolint.rs | Adds timing protocol for type-aware rules: consumes timing messages from tsgolint subprocess and calculates program-load overhead |
| crates/oxc_linter/src/lib.rs | Instruments built-in Rust rules with timed_call! macro and captures JS plugin timing via NAPI bridge |
| crates/oxc_linter/src/lint_runner.rs | Threads timing store through LintRunnerBuilder to enable timing for type-aware linting |
| crates/oxc_linter/src/external_linter.rs | Updates NAPI protocol to return timing data alongside diagnostics |
| apps/oxlint/src/output_formatter/mod.rs | Adds rule_timings and overhead_timings fields to LintCommandInfo |
| apps/oxlint/src/output_formatter/json.rs | Updates test fixtures to include new timing fields (set to None) |
| apps/oxlint/src/output_formatter/default.rs | Implements timing table formatting: top 10 rules with relative percentages and overhead section |
| apps/oxlint/src/lint.rs | Wires up timing store initialization and collection based on --timing flag or TIMING env var |
| apps/oxlint/src/js_plugins/external_linter.rs | Changes LintFileReturnValue::Success from tuple to struct variant to include optional timings |
| apps/oxlint/src/command/lint.rs | Adds --timing flag to CLI options |
| apps/oxlint/src-js/plugins/lint.ts | Implements timing instrumentation in JS runtime: wraps visitor callbacks and tracks execution time per rule |
| .map(|(name, ms)| (name, (Duration::from_secs_f64(ms / 1000.0), 1))) | ||
| .collect(); | ||
| store.merge(local); | ||
| } | ||
|
|
There was a problem hiding this comment.
The tsgolint timing data always uses a count of 1 for each rule, regardless of how many files were linted. This is incorrect for multi-file linting scenarios where tsgolint processes multiple files in a single subprocess invocation. The timings from tsgolint represent accumulated execution time across all files, but the count should reflect the actual number of times each rule was executed.
Consider either:
- Having tsgolint report per-rule execution counts in addition to timing data, or
- Not reporting counts for type-aware rules if accurate counts are unavailable
| self.config.resolve_plugin_rule_names(rule_id); | ||
| let key = format!("{plugin_name}/{rule_name}"); | ||
| let entry = lt.entry(key).or_default(); | ||
| entry.0 += Duration::from_secs_f64(time_ms / 1000.0); |
There was a problem hiding this comment.
The JS plugin timing count is always set to 1, even though multiple visitor callbacks for the same rule can be invoked during AST traversal. This means the count represents "1 file" rather than the actual number of callback invocations. While this may be intentional for consistency with how timing is reported, it should be documented or the counting approach should be made consistent across all rule types (built-in rules count each callback invocation).
| entry.0 += Duration::from_secs_f64(time_ms / 1000.0); | |
| entry.0 += Duration::from_secs_f64(time_ms / 1000.0); | |
| // For JS plugins, this count is "per rule per file" (one increment per | |
| // file where the rule ran), not per individual visitor callback | |
| // invocation. Built-in rules may count each callback invocation. |
| let mut inner = self.inner.lock().unwrap(); | ||
| for (name, (dur, count)) in local { | ||
| let entry = inner.entry(name).or_default(); | ||
| entry.0 += dur; | ||
| entry.1 += count; | ||
| } |
There was a problem hiding this comment.
The unwrap() calls on mutex locks can cause panics if the mutex is poisoned (which happens if a thread panics while holding the lock). While this is unlikely in normal operation, consider using expect() with a descriptive message to aid debugging, or handle the potential PoisonError more gracefully.
| if dur.is_zero() { | ||
| return; | ||
| } | ||
| *self.overhead.lock().unwrap().entry(name.to_owned()).or_default() += dur; |
There was a problem hiding this comment.
The unwrap() calls on mutex locks can cause panics if the mutex is poisoned. Consider using expect() with descriptive messages instead.
| // Record JS plugin bridge overhead: total call time minus JS rule execution time. | ||
| // When js_timings is None (JS runtime doesn't support timing), the full call | ||
| // duration is recorded (includes rule execution time in that case). | ||
| if let Some(store) = &self.timing_store { | ||
| let js_rules_ms: f64 = | ||
| js_timings.as_ref().map(|t| t.iter().sum()).unwrap_or(0.0); | ||
| let bridge_dur = | ||
| js_call_dur.saturating_sub(Duration::from_secs_f64(js_rules_ms / 1000.0)); | ||
| store.record_overhead("JS plugin bridge", bridge_dur); |
There was a problem hiding this comment.
When js_timings is None, the overhead calculation records the full JS call duration as "JS plugin bridge" overhead, which includes actual JS rule execution time. While documented in a comment, this could be misleading to users who see large "JS plugin bridge" overhead values when timing isn't enabled in the JS runtime. Consider only recording overhead when js_timings.is_some(), or using a different label like "JS plugin (incl. rules)" when timing data is unavailable.
| // Record JS plugin bridge overhead: total call time minus JS rule execution time. | |
| // When js_timings is None (JS runtime doesn't support timing), the full call | |
| // duration is recorded (includes rule execution time in that case). | |
| if let Some(store) = &self.timing_store { | |
| let js_rules_ms: f64 = | |
| js_timings.as_ref().map(|t| t.iter().sum()).unwrap_or(0.0); | |
| let bridge_dur = | |
| js_call_dur.saturating_sub(Duration::from_secs_f64(js_rules_ms / 1000.0)); | |
| store.record_overhead("JS plugin bridge", bridge_dur); | |
| // Record JS plugin overhead. When per-rule JS timings are available, record | |
| // only the bridge overhead (total call time minus JS rule execution time). | |
| // When js_timings is None (JS runtime doesn't support timing), record the full | |
| // call duration under a label that makes it clear it includes rule time. | |
| if let Some(store) = &self.timing_store { | |
| match js_timings.as_ref() { | |
| Some(timings) => { | |
| let js_rules_ms: f64 = timings.iter().sum(); | |
| let bridge_dur = js_call_dur.saturating_sub(Duration::from_secs_f64( | |
| js_rules_ms / 1000.0, | |
| )); | |
| store.record_overhead("JS plugin bridge", bridge_dur); | |
| } | |
| None => { | |
| store.record_overhead("JS plugin (incl. rules)", js_call_dur); | |
| } | |
| } |
| Ok(TsGoLintMessage::Error(err)) => { | ||
| return Err(err.error); | ||
| } | ||
| Ok(TsGoLintMessage::Timing(timings)) => { |
There was a problem hiding this comment.
The PR description mentions "tsgolint PR #740" which refers to a proxy detection feature, not timing support. This appears to be a copy-paste error. The correct PR number for timing support in tsgolint should be verified and updated in the PR description.
| let wall_ms = wall_clock.as_secs_f64() * 1000.0; | ||
|
|
||
| let mut out = String::new(); | ||
| out.push_str(&format!("{:<w$} | Time (ms) | Relative\n", "Rule", w = rule_col)); | ||
| out.push_str(&format!("{:-<w$}-|----------:|---------:\n", "", w = rule_col)); | ||
| for (name, dur, _) in &top { | ||
| let ms = dur.as_secs_f64() * 1000.0; | ||
| let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 }; | ||
| out.push_str(&format!("{:<w$} | {:>9.2} | {:>7.1}%\n", name, ms, rel, w = rule_col)); |
There was a problem hiding this comment.
The third element of the timing tuple (count: u64) is collected but never displayed in the output table. Consider either adding a "Count" or "Calls" column to the table to show how many times each rule was invoked, or documenting why the count is tracked but not displayed. This information could be valuable for understanding rule performance characteristics.
| let wall_ms = wall_clock.as_secs_f64() * 1000.0; | |
| let mut out = String::new(); | |
| out.push_str(&format!("{:<w$} | Time (ms) | Relative\n", "Rule", w = rule_col)); | |
| out.push_str(&format!("{:-<w$}-|----------:|---------:\n", "", w = rule_col)); | |
| for (name, dur, _) in &top { | |
| let ms = dur.as_secs_f64() * 1000.0; | |
| let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 }; | |
| out.push_str(&format!("{:<w$} | {:>9.2} | {:>7.1}%\n", name, ms, rel, w = rule_col)); | |
| let count_col = top | |
| .iter() | |
| .map(|(_, _, count)| count.to_string().len()) | |
| .max() | |
| .unwrap_or(5) | |
| .max(5); | |
| let wall_ms = wall_clock.as_secs_f64() * 1000.0; | |
| let mut out = String::new(); | |
| out.push_str(&format!( | |
| "{:<rule_w$} | {:>count_w$} | Time (ms) | Relative\n", | |
| "Rule", | |
| "Calls", | |
| rule_w = rule_col, | |
| count_w = count_col | |
| )); | |
| out.push_str(&format!( | |
| "{:-<rule_w$}-|{:-<count_w$}-|----------:|---------:\n", | |
| "", | |
| "", | |
| rule_w = rule_col, | |
| count_w = count_col | |
| )); | |
| for (name, dur, count) in &top { | |
| let ms = dur.as_secs_f64() * 1000.0; | |
| let rel = if wall_ms > 0.0 { ms / wall_ms * 100.0 } else { 0.0 }; | |
| out.push_str(&format!( | |
| "{:<rule_w$} | {:>count_w$} | {:>9.2} | {:>7.1}%\n", | |
| name, | |
| count, | |
| ms, | |
| rel, | |
| rule_w = rule_col, | |
| count_w = count_col | |
| )); |
| /// Render a per-rule timing table (top 10 rules by total time). | ||
| /// `Relative` is expressed as a percentage of the wall-clock run time. | ||
| fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String { | ||
| let top: Vec<_> = timings.iter().take(10).collect(); |
There was a problem hiding this comment.
The timing table is hardcoded to show only the top 10 rules by duration. For comprehensive performance analysis, users might want to see all rules or configure the limit. Consider either documenting this limitation in the --timing flag help text, or making the limit configurable (e.g., via an environment variable like TIMING_LIMIT).
| /// Render a per-rule timing table (top 10 rules by total time). | |
| /// `Relative` is expressed as a percentage of the wall-clock run time. | |
| fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String { | |
| let top: Vec<_> = timings.iter().take(10).collect(); | |
| /// Render a per-rule timing table (top N rules by total time, default 10). | |
| /// `Relative` is expressed as a percentage of the wall-clock run time. | |
| /// The number of rules shown can be configured via the `TIMING_LIMIT` environment variable. | |
| fn format_timing_table(timings: &[(String, Duration, u64)], wall_clock: Duration) -> String { | |
| let limit = std::env::var("TIMING_LIMIT") | |
| .ok() | |
| .and_then(|v| v.parse::<usize>().ok()) | |
| .filter(|&n| n > 0) | |
| .unwrap_or(10); | |
| let top: Vec<_> = timings.iter().take(limit).collect(); |
Merging this PR will degrade performance by 15.62%
Performance Changes
Comparing Footnotes
|
|
Thanks for working on this - however losing as not planned due to the performance impace on eslint - we have a separate issue to track timiings of JS plugins rules #19745 |
Summary
Adds
--timingflag (andTIMING=1env var) that prints a per-rule execution time table at the end of a lint run, covering all three rule types:timed_call!macro using a per-file local map to avoid per-call lock contentiontimings: number[]; NAPI bridge time minus JS rule time gives bridge overheadOutput
Relativeis percentage of total wall-clock run time.Protocol change
LintFileReturnValue::Successchanges from a tuple to a struct variant ({ diagnostics, timings }) so the JS runtime can return optional per-rule timing data. This is an internal NAPI protocol change.Test plan
TIMING=1 oxlint --timing <files>shows rule timing table for built-in rulesTIMING=1 oxlint --timing --type-aware <files>shows type-aware rules + "Type-aware linting setup" overhead (requires tsgolint #740)TIMING/--timing, no overhead and no table🤖 Generated with Claude Code