Linter perf: 27% faster on large files, 3% faster on project-wide lint #20766
Unanswered
alanzabihi
asked this question in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
I profiled ESLint's linting performance and found four optimizations worth sharing. Posting here to get feedback before opening any issues.
This was done with AI assistance (disclosed per the AI policy). I reviewed and tested everything myself.
How I measured
Two benchmarks against ESLint v10.2.0 on a dedicated server (Intel Xeon W-2295, 18c/36t, 256 GB DDR4, Node v25.9.0), alternating baseline and optimized runs to control for system noise:
Linter.verify()ontests/bench/large.js(19,500 lines) witheslint:recommended(57 rules). Median of 10 iterations per round.lib/directory, 388 files, ~98,000 lines, the project's owneslint.config.jswith 280 rules across core, unicorn, jsdoc, regexp, n, @eslint-community, and internal plugins. Median of 5 iterations per round.I tested both because optimizations that help one profile can hurt the other. Pre-allocating large typed arrays per file, for example, sped up single-file linting but regressed multi-file by ~8% from allocation overhead on small files.
Results
All four changes applied, 5 interleaved A/B rounds:
tests/bench/large.js, 57 rules)lib/, 388 files, 280 rules)Lint output is identical on both: same messages, same fingerprint, 0 errors / 0 warnings on the self-lint.
The four changes
I have working code for all of these on a fork. Each is independent except change 3, which depends on change 2.
Change 1:
Number.isSafeInteger()fast path inno-loss-of-precision(1 file, +17/-15 lines)Single-file: ~29 ms faster. Self-lint: neutral.
With
TIMING=all, this rule was 24.8% of total verify time on the single-file benchmark. Safe integers are exactly representable in IEEE 754, so the precision analysis is unnecessary for them. The fast path skips nearly all the work since most numeric literals in typical JS are small integers. All existing tests pass.Change 2: Per-node overhead reduction in traversal and CPA (4 files, +202/-89 lines)
Single-file: ~33 ms faster. Self-lint: ~111 ms faster.
Four small changes that are each below the noise floor individually but add up: a CPA node-type bypass for non-branching nodes, positional CPA emit args instead of array packing, a
callSyncSinglemethod that avoids rest/spread on ~250K dispatch calls, and plain object literals instead ofVisitNodeStep/CallMethodStepclass instances for ~193K traversal steps.Change 3: Pre-merge selector lists by node type (1 file, +58/-79 lines, depends on change 2)
Single-file: ~6 ms faster. Self-lint: ~61 ms faster.
calculateSelectors()merges two sorted arrays on every node visit, ~90K times per large file. The inputs are the same for every node of a given type, so this pre-computes the merged lists once at construction. #17019 tried something similar but couldn't measure a difference; combining it withcallSyncSinglefrom change 2 is what got it over the threshold.Change 4: Adaptive
createIndexMapfor large files (3 files, +54/-2 lines)Single-file: ~7 ms faster. Self-lint: neutral.
TokenStore eagerly builds a ~310K-entry hash map for every file. Since #17066 added binary search to
utils.search(), files over 10K tokens can skip the hash map and use O(log n) lookups instead. Small files keep the hash map because rules like jsdoc and prettier do thousands of token lookups per file. Also deferstokensAndCommentsto a lazy getter.Test status
Change 1 passes all existing tests. It only touches the rule.
Changes 2, 3, and 4 pass all behavioral tests: lint output is identical, the correctness fingerprint matches, the self-lint produces 0 errors / 0 warnings. But 29 internal-interface tests fail: 26 in
source-code-traverser.jsand 3 insource-code.js. These assert on implementation details like step object class types (VisitNodeStep,CallMethodStep), dispatch method signatures (calculateSelectorsvsdispatchSelectors), and traversal step shapes. The patches intentionally changed those internals. No rule behavior changed. The tests would need updating to match.What didn't work
I tried about 70 other approaches. Some of what I learned:
Uint8Arrayper file): 15ms faster single-file, 8% slower multi-file. Allocation cost on small files kills it.createIndexMapentirely: rules in heavy configs (jsdoc, prettier, import) need O(1) lookups. Binary search alone regresses.getScope()takes 1.73ms with near-100% WeakMap hit rate. Not a bottleneck.no-loss-of-precision, no individual rule costs >10ms. Total rule execution is ~29ms.parseSyncis ~67ms per large file but lives in espree/acorn, outside the editable surface.Concurrency
These are per-file, single-threaded changes. They stack with the multithread work in #19794: each worker gets faster
verify()calls.Next steps
I'd start with change 1 since it's small, self-contained, and passes all tests. The rest would follow in order.
All four changes are on a clean branch with one commit per change: alanzabihi/eslint:perf/linter-optimizations
Refs #16962
Beta Was this translation helpful? Give feedback.
All reactions