Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/batch-plugin-visitor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

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.
72 changes: 72 additions & 0 deletions .github/workflows/benchmark_grit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Benchmarks GritQL

on:
workflow_dispatch:
merge_group:
pull_request:
types: [ opened, synchronize ]
branches:
- main
- next
paths:
- 'Cargo.lock'
- 'crates/biome_grit_parser/**/*.rs'
- 'crates/biome_grit_patterns/**/*.rs'
- 'crates/biome_grit_syntax/**/*.rs'
- 'crates/biome_rowan/**/*.rs'
push:
branches:
- main
- next
paths:
- 'Cargo.lock'
- 'crates/biome_grit_parser/**/*.rs'
- 'crates/biome_grit_patterns/**/*.rs'
- 'crates/biome_grit_syntax/**/*.rs'
- 'crates/biome_rowan/**/*.rs'

env:
RUST_LOG: info

jobs:
bench:
permissions:
contents: read
pull-requests: write
name: Bench
runs-on: depot-ubuntu-24.04-arm-16
strategy:
matrix:
package:
- biome_grit_patterns

steps:

- name: Checkout PR Branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Install toolchain
uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 # v1.2.2
with:
channel: stable
cache-target: release
bins: cargo-codspeed
cache-base: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Compile
timeout-minutes: 20
run: cargo codspeed build -p ${{ matrix.package }}
env:
CARGO_BUILD_JOBS: 3 # Default is 4 (equals to the vCPU count of the runner), which leads OOM on cargo build

- name: Run the benchmarks
uses: CodSpeedHQ/action@4deb3275dd364fb96fb074c953133d29ec96f80f # v4.10.6
timeout-minutes: 50
with:
mode: simulation
run: cargo codspeed run
token: ${{ secrets.CODSPEED_TOKEN }}
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

156 changes: 154 additions & 2 deletions crates/biome_analyze/src/analyzer_plugin.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use camino::Utf8PathBuf;
use rustc_hash::FxHashSet;
use camino::{Utf8Path, Utf8PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use std::hash::Hash;
use std::{fmt::Debug, sync::Arc};

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

fn evaluate(&self, node: AnySyntaxNode, path: Arc<Utf8PathBuf>) -> Vec<RuleDiagnostic>;

/// Returns true if this plugin should run on the given file path.
///
/// Stub that always returns `true` — file-scoping will be implemented
/// in a companion PR (#9171) via the `includes` plugin option.
fn applies_to_file(&self, _path: &Utf8Path) -> bool {
true
}
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
Expand All @@ -37,6 +45,10 @@ pub enum PluginTargetLanguage {
pub struct PluginVisitor<L: Language> {
query: FxHashSet<L::Kind>,
plugin: Arc<Box<dyn AnalyzerPlugin>>,

/// When set, all nodes in this subtree are skipped until we leave it.
/// Used to skip subtrees that fall entirely outside the analysis range
/// (see the `ctx.range` check in `visit`).
skip_subtree: Option<SyntaxNode<L>>,
}

Expand Down Expand Up @@ -102,6 +114,10 @@ where
return;
}

if !self.plugin.applies_to_file(&ctx.options.file_path) {
return;
}

let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = self
.plugin
Expand All @@ -126,3 +142,139 @@ where
ctx.signal_queue.extend(signals);
}
}

/// A batched syntax visitor that evaluates multiple plugins in a single visitor.
///
/// Instead of registering N separate `PluginVisitor` instances (one per plugin),
/// this holds all plugins together and dispatches using a kind-to-plugin lookup
/// map. This reduces visitor-dispatch overhead and enables O(1) kind matching
/// per node instead of iterating all plugins.
pub struct BatchPluginVisitor<L: Language> {
plugins: Vec<Arc<Box<dyn AnalyzerPlugin>>>,

/// Maps each syntax kind to the indices of plugins that query for it.
kind_to_plugins: FxHashMap<L::Kind, Vec<usize>>,

/// When set, all nodes in this subtree are skipped until we leave it.
/// Used to skip subtrees that fall entirely outside the analysis range
/// (see the `ctx.range` check in `visit`).
skip_subtree: Option<SyntaxNode<L>>,

/// Cached per-plugin results of `applies_to_file`. Populated lazily on
/// first `WalkEvent::Enter` — the file path is constant for the entire walk.
applicable: Option<Vec<bool>>,
}

impl<L> BatchPluginVisitor<L>
where
L: Language + 'static,
L::Kind: Eq + Hash,
{
/// Creates a batched plugin visitor from a slice of plugins.
///
/// # Safety
/// Caller must ensure all plugins target language `L`. The `RawSyntaxKind`
/// values returned by each plugin's `query()` are converted to `L::Kind`
/// via `from_raw` without validation.
pub unsafe fn new_unchecked(plugins: AnalyzerPluginSlice) -> Self {
let mut all_plugins = Vec::with_capacity(plugins.len());
let mut kind_to_plugins: FxHashMap<L::Kind, Vec<usize>> = FxHashMap::default();

for (idx, plugin) in plugins.iter().enumerate() {
all_plugins.push(Arc::clone(plugin));
let mut seen_kinds = FxHashSet::default();
for raw_kind in plugin.query() {
let kind = L::Kind::from_raw(raw_kind);
if seen_kinds.insert(kind) {
kind_to_plugins.entry(kind).or_default().push(idx);
}
}
}

Self {
plugins: all_plugins,
kind_to_plugins,
skip_subtree: None,
applicable: None,
}
}
}

impl<L> Visitor for BatchPluginVisitor<L>
where
L: Language + 'static,
L::Kind: Eq + Hash,
{
type Language = L;

fn visit(
&mut self,
event: &WalkEvent<SyntaxNode<Self::Language>>,
ctx: VisitorContext<Self::Language>,
) {
let node = match event {
WalkEvent::Enter(node) => node,
WalkEvent::Leave(node) => {
if let Some(skip_subtree) = &self.skip_subtree
&& skip_subtree == node
{
self.skip_subtree = None;
}

return;
}
};

if self.skip_subtree.is_some() {
return;
}

if let Some(range) = ctx.range
&& node.text_range_with_trivia().ordering(range).is_ne()
{
self.skip_subtree = Some(node.clone());
return;
}

let kind = node.kind();

let Some(plugin_indices) = self.kind_to_plugins.get(&kind) else {
return;
};

let applicable = self.applicable.get_or_insert_with(|| {
self.plugins
.iter()
.map(|p| p.applies_to_file(&ctx.options.file_path))
.collect()
});

for &idx in plugin_indices {
if !applicable[idx] {
continue;
}

let plugin = &self.plugins[idx];
let rule_timer = profiling::start_plugin_rule("plugin");
let diagnostics = plugin.evaluate(node.clone().into(), ctx.options.file_path.clone());
rule_timer.stop();

let signals = diagnostics.into_iter().map(|diagnostic| {
let name = diagnostic
.subcategory
.clone()
.unwrap_or_else(|| "anonymous".into());

SignalEntry {
text_range: diagnostic.span().unwrap_or_default(),
signal: Box::new(PluginSignal::<L>::new(diagnostic)),
rule: SignalRuleKey::Plugin(name.into()),
category: RuleCategory::Lint,
instances: Default::default(),
}
});

ctx.signal_queue.extend(signals);
}
}
}
3 changes: 2 additions & 1 deletion crates/biome_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ mod visitor;
pub use biome_diagnostics::category_concat;

pub use crate::analyzer_plugin::{
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginTargetLanguage, PluginVisitor,
AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, BatchPluginVisitor,
PluginTargetLanguage, PluginVisitor,
};
pub use crate::categories::{
ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder,
Expand Down
24 changes: 14 additions & 10 deletions crates/biome_css_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ pub use crate::registry::visit_registry;
use crate::suppression_action::CssSuppressionAction;
use biome_analyze::{
AnalysisFilter, AnalyzerOptions, AnalyzerPluginSlice, AnalyzerSignal, AnalyzerSuppression,
ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases, PluginTargetLanguage,
PluginVisitor, RuleAction, RuleRegistry, to_analyzer_suppressions,
BatchPluginVisitor, ControlFlow, LanguageRoot, MatchQueryParams, MetadataRegistry, Phases,
PluginTargetLanguage, RuleAction, RuleRegistry, to_analyzer_suppressions,
};
use biome_css_syntax::{CssFileSource, CssLanguage, TextRange};
use biome_diagnostics::Error;
Expand Down Expand Up @@ -151,15 +151,19 @@ where
analyzer.add_visitor(phase, visitor);
}

for plugin in plugins {
// SAFETY: The plugin target language is correctly checked here.
let css_plugins: Vec<_> = plugins
.iter()
.filter(|p| p.language() == PluginTargetLanguage::Css)
.cloned()
.collect();

if !css_plugins.is_empty() {
// SAFETY: All plugins have been verified to target CSS above.
unsafe {
if plugin.language() == PluginTargetLanguage::Css {
analyzer.add_visitor(
Phases::Syntax,
Box::new(PluginVisitor::new_unchecked(plugin.clone())),
)
}
analyzer.add_visitor(
Phases::Syntax,
Box::new(BatchPluginVisitor::new_unchecked(&css_plugins)),
);
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/biome_grit_patterns/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ keywords.workspace = true
categories.workspace = true
publish = false

[[bench]]
harness = false
name = "grit_query"

[dependencies]
biome_analyze = { workspace = true }
biome_console = { workspace = true }
Expand Down Expand Up @@ -39,9 +43,16 @@ serde_json = { workspace = true, optional = true }

[dev-dependencies]
biome_test_utils = { path = "../biome_test_utils" }
criterion = { package = "codspeed-criterion-compat", version = "*" }
insta = { workspace = true }
tests_macros = { path = "../tests_macros" }

[target.'cfg(all(target_family="unix", not(all(target_arch = "aarch64", target_env = "musl"))))'.dev-dependencies]
tikv-jemallocator = { workspace = true }

[target.'cfg(target_os = "windows")'.dev-dependencies]
mimalloc = { workspace = true }

[features]
schema = ["biome_js_parser/schema", "dep:schemars", "serde"]
serde = ["dep:serde", "dep:serde_json"]
Expand Down
Loading