Skip to content

perf(linter): speed up hot rules on large codebases#23829

Closed
anonrig wants to merge 9 commits into
oxc-project:mainfrom
anonrig:perf/lint-rules-hot-paths
Closed

perf(linter): speed up hot rules on large codebases#23829
anonrig wants to merge 9 commits into
oxc-project:mainfrom
anonrig:perf/lint-rules-hot-paths

Conversation

@anonrig

@anonrig anonrig commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Speeds up lint rules that were prominent in --debug timings on a large monorepo (x-web). Optimizations focus on cheaper gates, fewer AST dispatches (run_once over unresolved references instead of every identifier), and avoiding work when rules are not applicable.

Benchmark (release oxlint on ~/workspace/x-web, 5763 files, 260 rules, --silent --threads=1 --debug timings, three runs averaged; baseline = timings from before this PR):

Rule Before (ms) After avg (ms) Calls (after) Speedup
oxc/bad-array-method-on-arguments 6.5 0.21 5.8k (was ~384k) ~31×
eslint/no-restricted-globals 7.1 0.36 5.8k (was ~384k) ~20×
oxc/no-this-in-exported-function 15.5 2.1 13.7k ~7.5×
vitest/no-commented-out-tests 13.4 2.1 5.8k ~6.4×
eslint/no-useless-call 10.6 2.6 150k ~4.0×
import/extensions 13.2 3.7 195k ~3.5×
jsx_a11y/interactive-supports-focus 7.7 2.4 38k ~3.2×
unicorn/prefer-number-properties 12.8 6.0 335k (was ~674k) ~2.1×
unicorn/no-thenable 7.6 3.9 210k ~1.9×
eslint/no-nonoctal-decimal-escape 6.5 3.6 179k ~1.8×
react/no-array-index-key 6.7 4.3 188k ~1.6×
eslint/no-obj-calls 6.5 4.7 156k ~1.4×
react/exhaustive-deps 14.7 11.5 150k ~1.3×
eslint/no-extend-native 7.5 5.7 5.8k ~1.3×
vitest/no-standalone-expect 7.1 6.0 5.8k ~1.2×
vitest/valid-expect 7.5 8.5 5.8k ~noise / shared jest collect cost
eslint/no-unused-vars 15.7 19.0 5.7k ~noise (export set still helps structure; wall time dominated by has_usages)

Wall clock for the timed run was ~16.7s on 5763 files with 1 thread (machine-dependent). Per-rule ms can vary ±20% across runs; large call-count drops (no-restricted-globals, bad-array-method-on-arguments, prefer-number-properties) are the durable wins.

Changes (high level)

Identifier / unresolved-reference paths

  • eslint/no-restricted-globals, oxc/bad-array-method-on-arguments: run_once over only relevant root_unresolved_references (not every IdentifierReference). Member access via window/globalThis handled from those refs when checkGlobalObject is set. Do not implement both run and run_once — that forces NODE_TYPES = None and visits every AST node.
  • unicorn/prefer-number-properties: bare NaN / Infinity / method shorthand via run_once + unresolved names; run only for calls / global object members (drops IdentifierReference from NODE_TYPES).
  • eslint/no-unused-vars: precompute exported local names once per file for O(1) export checks; skip has_usages for exported symbols.

Call / JSX / comment hot paths

  • react/exhaustive-deps: gate on use* / React.use* then reactive-hook classification before ancestor walks.
  • eslint/no-useless-call: fast path for plain StaticMemberExpression .call / .apply.
  • eslint/no-obj-calls: skip binding resolution for direct non-matching globals.
  • jsx_a11y/interactive-supports-focus: require interactive handler + role before element-type / a11y helpers.
  • react/no-array-index-key: skip ancestor walk without key=; method names via match.
  • vitest/jest no-commented-out-tests: hand-rolled multiline scanner (no regex on every comment) with cheap keyword prefilter.
  • import/extensions: no-op fast path for empty/default config; cheaper extension parsing (Cow for written extensions).
  • oxc/no-this-in-exported-function: skip ThisExpressionFinder when body text has no this.
  • unicorn/no-thenable: fast path for static then keys.
  • eslint/no-extend-native: ASCII-uppercase gate before is_ecma_script_global.
  • eslint/no-nonoctal-decimal-escape: byte scan for \8/\9 prefilter.
  • valid-expect / no-standalone-expect: drop extra sort; reserve node map (remaining cost is largely shared collect_possible_jest_call_node).

Test plan

  • cargo test -p oxc_linter --lib (1162 passed)
  • cargo lint / CI Lint job (-D warnings) after clippy single_match_else fix on no-useless-call
  • RUSTDOCFLAGS='-D warnings' cargo doc / CI Doc job
  • CI Test Linux, Conformance, Spell Check, Linter changes
  • Release build + oxlint . --silent --threads=1 --debug timings on ~/workspace/x-web (3 runs, table above)

AI-assisted implementation; human-reviewed before submit.

Optimize several rules that showed up in x-web --debug timings:

- eslint/no-unused-vars: precompute exported local names once per file
- oxc/no-this-in-exported-function: skip AST visit when body has no "this"
- react/exhaustive-deps: classify hooks before ancestor scope walks
- vitest/jest no-commented-out-tests: substring prefilter before regex
- import/extensions: avoid String allocs for common lowercase extensions
- unicorn/prefer-number-properties: name-filter identifiers before global lookup
- eslint/no-useless-call: early reject non-call/apply members

Benchmarked on ~/workspace/x-web with --threads=1 --debug timings.
@anonrig anonrig requested a review from camc314 as a code owner June 26, 2026 16:18
autofix-ci Bot and others added 5 commits June 26, 2026 16:19
Second pass on rules still prominent in large-repo timings:

- prefer-number-properties: move bare identifiers to run_once via
  root_unresolved_references (drops IdentifierReference from NODE_TYPES)
- no-commented-out-tests: replace regex with a hand-rolled multiline scanner
- exhaustive-deps: gate on use*/React.use* before name/hook classification
- import/extensions: no-op fast path for empty/default config
- no-useless-call: fast path for plain StaticMemberExpression callees
- no-unused-vars: skip has_usages walk for exported symbols

Also regenerate rule_runner_impls for prefer-number-properties.
- no-restricted-globals / bad-array-method-on-arguments: walk only
  relevant unresolved references (run_once) instead of every identifier
- no-obj-calls: skip binding resolution for non-matching direct globals
- no-nonoctal-decimal-escape: byte scan for \8/\9 prefilter
- interactive-supports-focus: cheap handler/role gates before element type
- no-array-index-key: skip ancestor walk without key=; match method names
- no-extend-native: ASCII uppercase gate before is_ecma_script_global
- no-thenable: fast path for static `then` object keys
- valid-expect / no-standalone-expect: avoid extra sort; reserve hash map
Use only `run_once` (not `run`+`run_once`) so lintgen keeps RUN_FUNCTIONS
as RunOnce and does not visit every AST node. Handle window/globalThis
member access from unresolved global-object references. Dedupe
global_objects when scanning.
@anonrig anonrig force-pushed the perf/lint-rules-hot-paths branch from 5a2d630 to a9cd740 Compare June 26, 2026 16:37
@codspeed-hq

codspeed-hq Bot commented Jun 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 5 untouched benchmarks
⏩ 66 skipped benchmarks1


Comparing anonrig:perf/lint-rules-hot-paths (90d1e68) with main (2a2d3b9)2

Open in CodSpeed

Footnotes

  1. 66 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.

  2. No successful run was found on main (5ac725d) during the generation of this report, so 2a2d3b9 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@camc314 camc314 added the A-linter Area - Linter label Jun 26, 2026
@camc314 camc314 self-assigned this Jun 26, 2026

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.

This optimization makes assumptions abount semantic that are incorrect. Hence this change is blocked by #23844

(no need to action this, I need to have a proper look next week)

graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Skip `ThisExpressionFinder` traversal when an exported function body does not contain the `this` substring.

Original large-monorepo timings reported in #23829: 15.5 ms before, 2.1 ms after (~7.5x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
…as bytes (#23850)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Scan string literal bytes for non-octal decimal escape sequences instead of decoding UTF-8 characters.
- Use `array_windows` to keep the adjacent-byte scan clear and readable.

Original large-monorepo timings reported in #23829: 6.5 ms before, 3.6 ms after (~1.8x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
…#23851)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Skip lowercase identifiers before checking the ECMAScript global tables.
- Preserve the existing Unicode-aware name filtering semantics while avoiding unnecessary global lookups.

Original large-monorepo timings reported in #23829: 7.5 ms before, 5.7 ms after (~1.3x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
…ons first (#23854)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Return early for JSX elements without interactive handlers or a static `role`.
- Delay element-type and accessibility settings lookups until the cheaper checks pass.

Original large-monorepo timings reported in #23829: 7.7 ms before, 2.4 ms after (~3.2x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
#23856)

- Split from #23829.
- Replace separate second- and third-index method arrays with a direct `match` that selects the callback index argument position.

**Stack**
 - #23857
 - #23856 👈 this PR!
 - #23855

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
)

Split from #23829.

Delay `find_index_param_symbol_id` until after finding a relevant JSX `key` attribute or `React.cloneElement` key property.

Use lazy, peekable iterators so the symbol lookup runs once only when needed while still checking every matching key. Add regression coverage for duplicate JSX attributes and object properties.

**Stack**
 - #23857 👈 this PR!
 - #23856
 - #23855

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 27, 2026
Split from #23829.

Adds a fast path when the comment definietly contains no test APIs
Replaces the regex with a manual impl to improve perf

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 28, 2026
Split from #23829.

Ports the shared valid-expect collection optimization from #23829 to avoid extra sorting work.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 28, 2026
Split from #23829.

Ports the shared no-standalone-expect collection optimization from #23829 to avoid extra sorting work.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 28, 2026
…ences (#23890)

Split from #23829.

Ports the no-restricted-globals run_once optimization from #23829 by scanning relevant unresolved references instead of every identifier.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
@camc314

camc314 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for putting this together! Theres a ton of useful perf work in here!

I split the pieces that were straightforward to validate into smaller PRs so each rule change had its own review surface, tests, and performance/correctness tradeoff. In retrospect this also makes the history much easier to bisect or revert if one rule-specific optimization behaves differently on a real codebase.

Pieces from this PR that have shipped separately:

I am splitting the remaining worthwhile pieces into separate follow-up PRs as well (no-useless-call, import/extensions, and no-obj-calls). I am going to leave out a couple of the smaller/riskier bits where the perf win did not look worth the complexity or where preserving deterministic behavior matters more.

Again, thank you for the benchmarking and implementaiton work here! The smaller PRs are mostly just to make review and maintenance easier, not because the overall direction was wrong.

Hope to see you around!

@camc314 camc314 closed this Jul 2, 2026
graphite-app Bot pushed a commit that referenced this pull request Jul 2, 2026
## Summary
- replace the small non-callable global list lookup with a direct `matches!` helper
- defer `resolve_global_binding` scoping/node setup until after its existing global-reference check

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jul 2, 2026
…ns (#24075)

## Summary
- split out the remaining safe `import/extensions` performance slice from #23829 for easier review and bisectability
- skip the rule work early when the config is effectively empty/default and there is nothing to enforce
- parse written extensions as `Cow<str>` so common lowercase specifiers stay borrowed
- preserve the existing written-extension behavior for cases like written `.js` resolving to `.ts`

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
graphite-app Bot pushed a commit that referenced this pull request Jul 2, 2026
## Summary
- Split the `eslint/no-useless-call` fast path out from #23829 for easier review and bisectability.
- Add a direct static-member callee classifier for `foo.call(...)` / `foo.apply(...)`, so common non-`.call` / non-`.apply` static calls return before the generic member-expression fallback.
- Keep the fallback for parenthesized, chained, computed, and optional-chain cases, with parenthesized regression coverage.

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
## Summary
- replace the small non-callable global list lookup with a direct `matches!` helper
- defer `resolve_global_binding` scoping/node setup until after its existing global-reference check

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…ns (#24075)

## Summary
- split out the remaining safe `import/extensions` performance slice from #23829 for easier review and bisectability
- skip the rule work early when the config is effectively empty/default and there is nothing to enforce
- parse written extensions as `Cow<str>` so common lowercase specifiers stay borrowed
- preserve the existing written-extension behavior for cases like written `.js` resolving to `.ts`

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
## Summary
- Split the `eslint/no-useless-call` fast path out from #23829 for easier review and bisectability.
- Add a direct static-member callee classifier for `foo.call(...)` / `foo.apply(...)`, so common non-`.call` / non-`.apply` static calls return before the generic member-expression fallback.
- Keep the fallback for parenthesized, chained, computed, and optional-chain cases, with parenthesized regression coverage.

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Skip `ThisExpressionFinder` traversal when an exported function body does not contain the `this` substring.

Original large-monorepo timings reported in #23829: 15.5 ms before, 2.1 ms after (~7.5x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…as bytes (#23850)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Scan string literal bytes for non-octal decimal escape sequences instead of decoding UTF-8 characters.
- Use `array_windows` to keep the adjacent-byte scan clear and readable.

Original large-monorepo timings reported in #23829: 6.5 ms before, 3.6 ms after (~1.8x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…#23851)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Skip lowercase identifiers before checking the ECMAScript global tables.
- Preserve the existing Unicode-aware name filtering semantics while avoiding unnecessary global lookups.

Original large-monorepo timings reported in #23829: 7.5 ms before, 5.7 ms after (~1.3x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…ons first (#23854)

- Split from #23829; original implementation by Yagiz Nizipli, retained as a commit coauthor.
- Return early for JSX elements without interactive handlers or a static `role`.
- Delay element-type and accessibility settings lookups until the cheaper checks pass.

Original large-monorepo timings reported in #23829: 7.7 ms before, 2.4 ms after (~3.2x).

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
#23856)

- Split from #23829.
- Replace separate second- and third-index method arrays with a direct `match` that selects the callback index argument position.

**Stack**
 - #23857
 - #23856 👈 this PR!
 - #23855

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
)

Split from #23829.

Delay `find_index_param_symbol_id` until after finding a relevant JSX `key` attribute or `React.cloneElement` key property.

Use lazy, peekable iterators so the symbol lookup runs once only when needed while still checking every matching key. Add regression coverage for duplicate JSX attributes and object properties.

**Stack**
 - #23857 👈 this PR!
 - #23856
 - #23855

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
Split from #23829.

Adds a fast path when the comment definietly contains no test APIs
Replaces the regex with a manual impl to improve perf

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…#23880)

Split from #23829.

Move bare `NaN`/`Infinity` and method shorthand handling to `run_once` over root unresolved references, leaving call expressions and global-object member replacements in `run`.

This reduces identifier-reference visits on large files while preserving behavior.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
)

Split from #23829.

Precompute exported local names once per file so `eslint/no-unused-vars` can check exported bindings in O(1) instead of repeatedly scanning export entries.

Skip usage walks for exported symbols and adapt the single-symbol import-fixer call site to preserve behavior.

AI-assisted with Codex.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…3882)

Split from #23829.

Ports the exhaustive-deps hook-classification fast path from #23829 to avoid ancestor walks for non-reactive calls.

Classify reactive hooks before the enclosing-function scope lookup so ordinary calls can return immediately while preserving behavior.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
Split from #23829.

Ports the shared no-standalone-expect collection optimization from #23829 to avoid extra sorting work.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…ences (#23890)

Split from #23829.

Ports the no-restricted-globals run_once optimization from #23829 by scanning relevant unresolved references instead of every identifier.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
## Summary
- replace the small non-callable global list lookup with a direct `matches!` helper
- defer `resolve_global_binding` scoping/node setup until after its existing global-reference check

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
…ns (#24075)

## Summary
- split out the remaining safe `import/extensions` performance slice from #23829 for easier review and bisectability
- skip the rule work early when the config is effectively empty/default and there is nothing to enforce
- parse written extensions as `Cow<str>` so common lowercase specifiers stay borrowed
- preserve the existing written-extension behavior for cases like written `.js` resolving to `.ts`

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
## Summary
- Split the `eslint/no-useless-call` fast path out from #23829 for easier review and bisectability.
- Add a direct static-member callee classifier for `foo.call(...)` / `foo.apply(...)`, so common non-`.call` / non-`.apply` static calls return before the generic member-expression fallback.
- Keep the fallback for parenthesized, chained, computed, and optional-chain cases, with parenthesized regression coverage.

Credit to Yagiz Nizipli for the original work in #23829.

Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants