Skip to content

fix(linter): skip per-node dispatch for run_once-only rules in large files#22398

Merged
camchenry merged 1 commit into
oxc-project:mainfrom
connorshea:fix-linter-run-once-per-node-dispatch
May 14, 2026
Merged

fix(linter): skip per-node dispatch for run_once-only rules in large files#22398
camchenry merged 1 commit into
oxc-project:mainfrom
connorshea:fix-linter-run-once-per-node-dispatch

Conversation

@connorshea

@connorshea connorshea commented May 14, 2026

Copy link
Copy Markdown
Member

Fix #22396. Below is Claude's explanation of the fix. I haven't tested this deeply or fully understood the code yet, so it may be fully wrong, but on first look it does appear to solve the call count problem at the very least.


Summary

The large-files branch (>200k AST nodes) in execute_rules was adding every rule without bucketable AST types to rules_any_ast_type, then calling rule.run per node on the entire bucket — including run_once-only rules whose run is the default empty impl.

With --debug timings, each of those per-node calls increments Calls and adds time() overhead, inflating the "boring rules floor" (unicode-bom, no-dupe-class-members, no-this-before-super, no-redeclare, no-unused-private-class-members, jsx-filename-extension, etc.) by N nodes per large file.

This is what makes those rules cluster near a fixed ~11ms / similar call count in --debug timings output even on codebases where they have almost no real work to do: a handful of large generated/bundled files dominate the count.

The fix: gate the else branch on is_run_implemented() so run_once-only rules don't get dispatched through run per node.

Reproduction

Before:

$ oxlint --debug timings -A all -W unicode-bom big.js  # 60k-line file, 240k AST nodes
...
eslint/unicode-bom       2.857    100.0%  240002  native

After:

$ oxlint --debug timings -A all -W unicode-bom big.js
...
eslint/unicode-bom       0.048    100.0%       1  native

Impact

  • --debug timings output: significant correction. The rule timings table now reflects real cost.
  • Production (non---debug): small win on files >200k nodes from skipping per-node dispatch into empty run; unmeasurable on typical files.

Test plan

  • cargo test -p oxc_linter --lib (1119 tests pass)
  • Verified large-file fix empirically (240,002 → 1 call for unicode-bom on a 240k-node file)
  • Verified small-file path unchanged (multi-file test still reports correct per-file call counts)

🤖 Generated with Claude Code

…e files

The large-files branch (>200k AST nodes) in `execute_rules` added every
rule without bucketable AST types to `rules_any_ast_type`, then called
`rule.run` per node on the whole bucket — including `run_once`-only
rules whose `run` is the default empty impl. With `--debug timings`,
each call increments `Calls` and adds `time()` overhead, inflating the
"boring rules floor" (unicode-bom, no-dupe-class-members,
no-this-before-super, etc.) by N nodes per large file.

Gate the else branch on `is_run_implemented()` so `run_once`-only rules
are dispatched once via `run_once` and not per node.

Verified: a 240k-node file dropped `unicode-bom` from 240,002 calls to 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented May 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 47 skipped benchmarks1


Comparing connorshea:fix-linter-run-once-per-node-dispatch (47e7030) with main (3b46a8d)

Open in CodSpeed

Footnotes

  1. 47 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@camchenry

camchenry commented May 14, 2026

Copy link
Copy Markdown
Member

At first glance, this seems like the correct change. Just thinking out loud since it's been quite a few months since I touched the node types optimized code: rules_any_ast_type is only used to run the run function of rules. That Vec is intended to hold rules that could run on any AST node type (i.e., we don't know what node types it looks at, so we have to run it on all of them). It doesn't make sense to include rules that don't implement run in there, because they wouldn't have any effect.

I'll sleep on it and take another look in the morning, but otherwise I think this seems good to merge.

@camchenry

Copy link
Copy Markdown
Member

Also looking at the benchmarks, I see a 14% decrease in self time in run_with_disable_directives, which is basically what I would expect to see if we spend less time looping over rules in that function:

14% decrease in run_with_disable_directives self time

However, it seems to be eaten up by some other code path that is now getting hit. (Looks like .next() on oxc_semantic::node::AstNode? Not sure.) Otherwise, this should be a noticeable perf win.

@connorshea

Copy link
Copy Markdown
Member Author

When benchmarking the release binary from main vs. my "fixed" binary from this branch, sometimes I get a 1.07x, other times it's identical. Not really sure if that means anything, but thus far I have not seen any result that would suggest this is ever less performant, which is good.

connorshea@Connors-MacBook-Pro actual % hyperfine --warmup 3 --runs 50 '~/code/oxc/target/release/oxlint' '~/Desktop/oxlint-fixed'
Benchmark 1: ~/code/oxc/target/release/oxlint
  Time (mean ± σ):     256.1 ms ±   2.1 ms    [User: 833.0 ms, System: 656.4 ms]
  Range (min … max):   252.4 ms … 262.1 ms    50 runs

Benchmark 2: ~/Desktop/oxlint-fixed
  Time (mean ± σ):     255.7 ms ±  10.5 ms    [User: 804.4 ms, System: 865.3 ms]
  Range (min … max):   237.6 ms … 280.5 ms    50 runs

Summary
  ~/Desktop/oxlint-fixed ran
    1.00 ± 0.04 times faster than ~/code/oxc/target/release/oxlint
connorshea@Connors-MacBook-Pro actual % hyperfine --warmup 3 --runs 50 '~/code/oxc/target/release/oxlint' '~/Desktop/oxlint-fixed'
Benchmark 1: ~/code/oxc/target/release/oxlint
  Time (mean ± σ):     258.1 ms ±   4.8 ms    [User: 835.8 ms, System: 672.0 ms]
  Range (min … max):   252.8 ms … 276.7 ms    50 runs

Benchmark 2: ~/Desktop/oxlint-fixed
  Time (mean ± σ):     241.5 ms ±   2.4 ms    [User: 811.0 ms, System: 649.6 ms]
  Range (min … max):   235.5 ms … 245.9 ms    50 runs

Summary
  ~/Desktop/oxlint-fixed ran
    1.07 ± 0.02 times faster than ~/code/oxc/target/release/oxlint

@connorshea connorshea marked this pull request as ready for review May 14, 2026 04:22
@connorshea connorshea requested a review from camc314 as a code owner May 14, 2026 04:22
Copilot AI review requested due to automatic review settings May 14, 2026 04:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the large-file execution path in oxc_linter by avoiding per-AST-node dispatch for rules that only implement run_once (i.e., rules where run is not implemented). This addresses inflated --debug timings “Calls” and associated overhead for large ASTs (>200k nodes), aligning timing output more closely with actual work performed.

Changes:

  • In the large-file branch of execute_rules, only enqueue rules into the “any AST type” per-node dispatch list when run is actually implemented (or when runtime optimization is disabled).
  • Keeps run_once behavior unchanged: run_once-only rules still execute exactly once per file (as intended), while avoiding N-node run calls with a default empty implementation.

@camchenry

Copy link
Copy Markdown
Member

When benchmarking the release binary from main vs. my "fixed" binary from this branch, sometimes I get a 1.07x, other times it's identical. Not really sure if that means anything, but thus far I have not seen any result that would suggest this is ever less performant, which is good.

The minimum time is consistently decreased, and the slowest run with the optimized code is still faster than the slowest run of the non-optimized code. So even without computing any statistical significance, that seems like a strong signal this is worthwhile to me.

After thinking on it, I believe this is the correct behavior. I can't imagine any regressions this might cause. I'm actually a little surprised we didn't catch this earlier! So thank you for reporting this.

@camchenry camchenry added the 0-merge Merge with Graphite Merge Queue label May 14, 2026

camchenry commented May 14, 2026

Copy link
Copy Markdown
Member

Merge activity

  • May 14, 3:22 PM UTC: The merge label '0-merge' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • May 14, 3:22 PM UTC: camchenry added this pull request to the Graphite merge queue.
  • May 14, 3:23 PM UTC: The Graphite merge queue couldn't merge this PR because it failed for an unknown reason (Fast-forward merges are not supported for forked repositories. Please create a branch in the target repository in order to merge).

@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label May 14, 2026
@camchenry camchenry merged commit 3c1bb6f into oxc-project:main May 14, 2026
36 checks passed
overlookmotel added a commit that referenced this pull request May 15, 2026
# Oxlint
### 🚀 Features

- 5478fb5 linter/jsdoc: Implement `require-throws-description` rule
(#22386) (Mikhail Baev)
- b46d4de linter: Add `--debug` options and add per-rule timing info
(#22282) (camchenry)
- c73225e linter/eslint: Implement `prefer-arrow-callback` rule (#22312)
(박천(Cheon Park))
- de82b59 linter: Add support for `eslint-plugin-jsx-a11y-x` (#22356)
(mehm8128)
- b170da3 linter: Implement no-implicit-globals (#22249) (Jovi De
Croock)
- f44b6c8 linter: Fill schemas `DummyRuleMap` with built-in rules
(#22288) (Sysix)
- 5cdb80d linter/jsx-a11y/: Implement
no-interactive-element-to-noninteractive-role (#22332) (anarefolio)
- 2749422 linter/jsx-a11y: Add no-noninteractive-element-interactions
(#22337) (Pablo Tovar)
- ba2a1d3 linter/jsdoc: Implement `require-throws-type` rule (#22358)
(Mikhail Baev)
- d90729d linter/jsx-a11y: Implement control-has-associated-label
(#21985) (mehm8128)
- 1d04903 linter/jsdoc: Implement `require-yields-type` rule (#22331)
(Mikhail Baev)

### 🐛 Bug Fixes

- 04c4609 linter/no-nullable-type-assertion-style: Mark as suggestion
(#22450) (camc314)
- 1c2b7ec linter/no-unused-vars: Handle shadowed self assignments
(#22387) (camc314)
- 9faa1d5 linter/no-noninteractive-tabindex: Check conditional
expressions (#22435) (camc314)
- 0854b3a linter/prefer-arrow-callback: Preserve TSX generic arrows in
fixer (#22434) (camc314)
- 410b814 linter: Supply `source_type` to codegen fixer (#22433)
(camc314)
- 3c1bb6f linter: Skip per-node dispatch for run_once-only rules in
large files (#22398) (Connor Shea)
- 5206cde linter/no-unused-vars: Improve type-only rest parameters
diagnostic (#22385) (camc314)
- c9a22b5 linter/consistent-function-scoping: Allow imported bindings
(#22384) (camc314)
- c1e966d linter: Report type-only unused parameters in no-unused-vars
(#22368) (camchenry)
- 4818d98 linter: Check whether path is under root before ignoring it
(#20101) (Leonabcd123)
- 41fcdcf linter: Fix rule count not including override rules (#19898)
(Daniel Osmond)
- 59b4f0e linter: Fix 'explicit-module-boundary-types' false positive
with 'allowOverloadFunctions' (#22341) (camchenry)

### ⚡ Performance

- 6d42395 linter: Narrow no-unsafe-optional-chaining dispatch (#22437)
(camchenry)
- 08595fb linter: Optimize no-unreachable (#22397) (camchenry)
- 3b46a8d linter: Optimize `no-loss-of-precision` (#22395) (camchenry)
- b3e2dc9 linter: Optimize `oxc/bad-array-method-on-arguments` (#22393)
(camchenry)

### 📚 Documentation

- dcbf62c linter: Remove some duplicate spaces (#22359) (camc314)
# Oxfmt
### 💥 BREAKING CHANGES

- 21bb5d1 oxfmt: [**BREAKING**] Avoid config pre-scan (#22258)
(leaysgur)

### 🐛 Bug Fixes

- 441d724 oxfmt: Fix "race probe" logic with unit tests (#22378)
(leaysgur)
- e49ee26 formatter: Respect `singleQuote` for jsdoc `import()` type
paths (#22353) (Colin Lienard)
- 43b9978 formatter/sort_imports: Treat subpath imports as internal
(#22440) (leaysgur)
- 7c5cfa0 formatter: Handle jsx trailing comment with parens (#22370)
(leaysgur)
- ac5f120 formatter: Fix erroneous formatting inside a template literal
with parentheses (#22262) (Jovi De Croock)
- 3c53a95 formatter/sort_imports: Handle ignore comment as boundary
(#22369) (leaysgur)
- 4dd83dd oxfmt: Send expandedStates variants as shared refs (#22366)
(leaysgur)
- 055cc61 formatter: Expand JSX logical chain with leading line comment
(#22346) (leaysgur)
- 8046222 formatter: Preserve type alias comment break (#22261) (Jovi De
Croock)

### ⚡ Performance

- 123c493 oxfmt: Reduce more syscalls (#22380) (leaysgur)

Co-authored-by: overlookmotel <557937+overlookmotel@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

linter: no-unused-labels rule doesn't have an AST guard

3 participants