Skip to content

Commit 49c8fde

Browse files
perf(linter): batch plugins into single visitor with anchor-kind dispatch (#9184)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 0385eb2 commit 49c8fde

14 files changed

Lines changed: 875 additions & 46 deletions

File tree

.changeset/batch-plugin-visitor.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Improved plugin performance by batching all plugins into a single syntax visitor with a kind-to-plugin lookup map, reducing per-node dispatch overhead from O(N) to O(1) where N is the number of plugins.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: Benchmarks GritQL
2+
3+
on:
4+
workflow_dispatch:
5+
merge_group:
6+
pull_request:
7+
types: [ opened, synchronize ]
8+
branches:
9+
- main
10+
- next
11+
paths:
12+
- 'Cargo.lock'
13+
- 'crates/biome_grit_parser/**/*.rs'
14+
- 'crates/biome_grit_patterns/**/*.rs'
15+
- 'crates/biome_grit_syntax/**/*.rs'
16+
- 'crates/biome_rowan/**/*.rs'
17+
push:
18+
branches:
19+
- main
20+
- next
21+
paths:
22+
- 'Cargo.lock'
23+
- 'crates/biome_grit_parser/**/*.rs'
24+
- 'crates/biome_grit_patterns/**/*.rs'
25+
- 'crates/biome_grit_syntax/**/*.rs'
26+
- 'crates/biome_rowan/**/*.rs'
27+
28+
env:
29+
RUST_LOG: info
30+
31+
jobs:
32+
bench:
33+
permissions:
34+
contents: read
35+
pull-requests: write
36+
name: Bench
37+
runs-on: depot-ubuntu-24.04-arm-16
38+
strategy:
39+
matrix:
40+
package:
41+
- biome_grit_patterns
42+
43+
steps:
44+
45+
- name: Checkout PR Branch
46+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
47+
with:
48+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
49+
50+
- name: Install toolchain
51+
uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 # v1.2.2
52+
with:
53+
channel: stable
54+
cache-target: release
55+
bins: cargo-codspeed
56+
cache-base: main
57+
env:
58+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
60+
- name: Compile
61+
timeout-minutes: 20
62+
run: cargo codspeed build -p ${{ matrix.package }}
63+
env:
64+
CARGO_BUILD_JOBS: 3 # Default is 4 (equals to the vCPU count of the runner), which leads OOM on cargo build
65+
66+
- name: Run the benchmarks
67+
uses: CodSpeedHQ/action@4deb3275dd364fb96fb074c953133d29ec96f80f # v4.10.6
68+
timeout-minutes: 50
69+
with:
70+
mode: simulation
71+
run: cargo codspeed run
72+
token: ${{ secrets.CODSPEED_TOKEN }}

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_analyze/src/analyzer_plugin.rs

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
use camino::Utf8PathBuf;
2-
use rustc_hash::FxHashSet;
1+
use camino::{Utf8Path, Utf8PathBuf};
2+
use rustc_hash::{FxHashMap, FxHashSet};
33
use std::hash::Hash;
44
use std::{fmt::Debug, sync::Arc};
55

@@ -23,6 +23,14 @@ pub trait AnalyzerPlugin: Debug + Send + Sync {
2323
fn query(&self) -> Vec<RawSyntaxKind>;
2424

2525
fn evaluate(&self, node: AnySyntaxNode, path: Arc<Utf8PathBuf>) -> Vec<RuleDiagnostic>;
26+
27+
/// Returns true if this plugin should run on the given file path.
28+
///
29+
/// Stub that always returns `true` — file-scoping will be implemented
30+
/// in a companion PR (#9171) via the `includes` plugin option.
31+
fn applies_to_file(&self, _path: &Utf8Path) -> bool {
32+
true
33+
}
2634
}
2735

2836
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
@@ -37,6 +45,10 @@ pub enum PluginTargetLanguage {
3745
pub struct PluginVisitor<L: Language> {
3846
query: FxHashSet<L::Kind>,
3947
plugin: Arc<Box<dyn AnalyzerPlugin>>,
48+
49+
/// When set, all nodes in this subtree are skipped until we leave it.
50+
/// Used to skip subtrees that fall entirely outside the analysis range
51+
/// (see the `ctx.range` check in `visit`).
4052
skip_subtree: Option<SyntaxNode<L>>,
4153
}
4254

@@ -102,6 +114,10 @@ where
102114
return;
103115
}
104116

117+
if !self.plugin.applies_to_file(&ctx.options.file_path) {
118+
return;
119+
}
120+
105121
let rule_timer = profiling::start_plugin_rule("plugin");
106122
let diagnostics = self
107123
.plugin
@@ -126,3 +142,139 @@ where
126142
ctx.signal_queue.extend(signals);
127143
}
128144
}
145+
146+
/// A batched syntax visitor that evaluates multiple plugins in a single visitor.
147+
///
148+
/// Instead of registering N separate `PluginVisitor` instances (one per plugin),
149+
/// this holds all plugins together and dispatches using a kind-to-plugin lookup
150+
/// map. This reduces visitor-dispatch overhead and enables O(1) kind matching
151+
/// per node instead of iterating all plugins.
152+
pub struct BatchPluginVisitor<L: Language> {
153+
plugins: Vec<Arc<Box<dyn AnalyzerPlugin>>>,
154+
155+
/// Maps each syntax kind to the indices of plugins that query for it.
156+
kind_to_plugins: FxHashMap<L::Kind, Vec<usize>>,
157+
158+
/// When set, all nodes in this subtree are skipped until we leave it.
159+
/// Used to skip subtrees that fall entirely outside the analysis range
160+
/// (see the `ctx.range` check in `visit`).
161+
skip_subtree: Option<SyntaxNode<L>>,
162+
163+
/// Cached per-plugin results of `applies_to_file`. Populated lazily on
164+
/// first `WalkEvent::Enter` — the file path is constant for the entire walk.
165+
applicable: Option<Vec<bool>>,
166+
}
167+
168+
impl<L> BatchPluginVisitor<L>
169+
where
170+
L: Language + 'static,
171+
L::Kind: Eq + Hash,
172+
{
173+
/// Creates a batched plugin visitor from a slice of plugins.
174+
///
175+
/// # Safety
176+
/// Caller must ensure all plugins target language `L`. The `RawSyntaxKind`
177+
/// values returned by each plugin's `query()` are converted to `L::Kind`
178+
/// via `from_raw` without validation.
179+
pub unsafe fn new_unchecked(plugins: AnalyzerPluginSlice) -> Self {
180+
let mut all_plugins = Vec::with_capacity(plugins.len());
181+
let mut kind_to_plugins: FxHashMap<L::Kind, Vec<usize>> = FxHashMap::default();
182+
183+
for (idx, plugin) in plugins.iter().enumerate() {
184+
all_plugins.push(Arc::clone(plugin));
185+
let mut seen_kinds = FxHashSet::default();
186+
for raw_kind in plugin.query() {
187+
let kind = L::Kind::from_raw(raw_kind);
188+
if seen_kinds.insert(kind) {
189+
kind_to_plugins.entry(kind).or_default().push(idx);
190+
}
191+
}
192+
}
193+
194+
Self {
195+
plugins: all_plugins,
196+
kind_to_plugins,
197+
skip_subtree: None,
198+
applicable: None,
199+
}
200+
}
201+
}
202+
203+
impl<L> Visitor for BatchPluginVisitor<L>
204+
where
205+
L: Language + 'static,
206+
L::Kind: Eq + Hash,
207+
{
208+
type Language = L;
209+
210+
fn visit(
211+
&mut self,
212+
event: &WalkEvent<SyntaxNode<Self::Language>>,
213+
ctx: VisitorContext<Self::Language>,
214+
) {
215+
let node = match event {
216+
WalkEvent::Enter(node) => node,
217+
WalkEvent::Leave(node) => {
218+
if let Some(skip_subtree) = &self.skip_subtree
219+
&& skip_subtree == node
220+
{
221+
self.skip_subtree = None;
222+
}
223+
224+
return;
225+
}
226+
};
227+
228+
if self.skip_subtree.is_some() {
229+
return;
230+
}
231+
232+
if let Some(range) = ctx.range
233+
&& node.text_range_with_trivia().ordering(range).is_ne()
234+
{
235+
self.skip_subtree = Some(node.clone());
236+
return;
237+
}
238+
239+
let kind = node.kind();
240+
241+
let Some(plugin_indices) = self.kind_to_plugins.get(&kind) else {
242+
return;
243+
};
244+
245+
let applicable = self.applicable.get_or_insert_with(|| {
246+
self.plugins
247+
.iter()
248+
.map(|p| p.applies_to_file(&ctx.options.file_path))
249+
.collect()
250+
});
251+
252+
for &idx in plugin_indices {
253+
if !applicable[idx] {
254+
continue;
255+
}
256+
257+
let plugin = &self.plugins[idx];
258+
let rule_timer = profiling::start_plugin_rule("plugin");
259+
let diagnostics = plugin.evaluate(node.clone().into(), ctx.options.file_path.clone());
260+
rule_timer.stop();
261+
262+
let signals = diagnostics.into_iter().map(|diagnostic| {
263+
let name = diagnostic
264+
.subcategory
265+
.clone()
266+
.unwrap_or_else(|| "anonymous".into());
267+
268+
SignalEntry {
269+
text_range: diagnostic.span().unwrap_or_default(),
270+
signal: Box::new(PluginSignal::<L>::new(diagnostic)),
271+
rule: SignalRuleKey::Plugin(name.into()),
272+
category: RuleCategory::Lint,
273+
instances: Default::default(),
274+
}
275+
});
276+
277+
ctx.signal_queue.extend(signals);
278+
}
279+
}
280+
}

crates/biome_analyze/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ mod visitor;
3030
pub use biome_diagnostics::category_concat;
3131

3232
pub use crate::analyzer_plugin::{
33-
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginTargetLanguage, PluginVisitor,
33+
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, BatchPluginVisitor,
34+
PluginTargetLanguage, PluginVisitor,
3435
};
3536
pub use crate::categories::{
3637
ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder,

crates/biome_css_analyze/src/lib.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ pub use crate::registry::visit_registry;
1414
use crate::suppression_action::CssSuppressionAction;
1515
use biome_analyze::{
1616
AnalysisFilter, AnalyzerOptions, AnalyzerPluginSlice, AnalyzerSignal, AnalyzerSuppression,
17-
ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases, PluginTargetLanguage,
18-
PluginVisitor, RuleAction, RuleRegistry, to_analyzer_suppressions,
17+
BatchPluginVisitor, ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases,
18+
PluginTargetLanguage, RuleAction, RuleRegistry, to_analyzer_suppressions,
1919
};
2020
use biome_css_syntax::{CssFileSource, CssLanguage, TextRange};
2121
use biome_diagnostics::Error;
@@ -154,15 +154,19 @@ where
154154
analyzer.add_visitor(phase, visitor);
155155
}
156156

157-
for plugin in plugins {
158-
// SAFETY: The plugin target language is correctly checked here.
157+
let css_plugins: Vec<_> = plugins
158+
.iter()
159+
.filter(|p| p.language() == PluginTargetLanguage::Css)
160+
.cloned()
161+
.collect();
162+
163+
if !css_plugins.is_empty() {
164+
// SAFETY: All plugins have been verified to target CSS above.
159165
unsafe {
160-
if plugin.language() == PluginTargetLanguage::Css {
161-
analyzer.add_visitor(
162-
Phases::Syntax,
163-
Box::new(PluginVisitor::new_unchecked(plugin.clone())),
164-
)
165-
}
166+
analyzer.add_visitor(
167+
Phases::Syntax,
168+
Box::new(BatchPluginVisitor::new_unchecked(&css_plugins)),
169+
);
166170
}
167171
}
168172

crates/biome_grit_patterns/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ keywords.workspace = true
1111
categories.workspace = true
1212
publish = false
1313

14+
[[bench]]
15+
harness = false
16+
name = "grit_query"
17+
1418
[dependencies]
1519
biome_analyze = { workspace = true }
1620
biome_console = { workspace = true }
@@ -39,9 +43,16 @@ serde_json = { workspace = true, optional = true }
3943

4044
[dev-dependencies]
4145
biome_test_utils = { path = "../biome_test_utils" }
46+
criterion = { package = "codspeed-criterion-compat", version = "*" }
4247
insta = { workspace = true }
4348
tests_macros = { path = "../tests_macros" }
4449

50+
[target.'cfg(all(target_family="unix", not(all(target_arch = "aarch64", target_env = "musl"))))'.dev-dependencies]
51+
tikv-jemallocator = { workspace = true }
52+
53+
[target.'cfg(target_os = "windows")'.dev-dependencies]
54+
mimalloc = { workspace = true }
55+
4556
[features]
4657
schema = ["biome_js_parser/schema", "dep:schemars", "serde"]
4758
serde = ["dep:serde", "dep:serde_json"]

0 commit comments

Comments
 (0)