editor: Improve find_matches and replace_all perf (#51941)

Helps with https://github.com/zed-industries/zed/issues/38927

- **editor: Add a benchmark for find/replace**
- **text: batch fragment insertions before turning them into a SumTree**

## Context

<!-- What does this PR do, and why? How is it expected to impact users?
     Not just what changed, but what motivated it and why this approach.

Link to Linear issue (e.g., ENG-123) or GitHub issue (e.g., Closes #456)
     if one exists — helps with traceability. -->

## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Improved performance of "Replace All" in buffer search

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Piotr Osiewicz
2026-05-06 09:28:05 +00:00
committed by GitHub
co-authored by Smit Barmase
parent f5945344cc
commit cc279a46fa
8 changed files with 506 additions and 39 deletions
Generated
+18
View File
@@ -5549,6 +5549,24 @@ dependencies = [
"ztracing",
]
[[package]]
name = "editor_benchmarks"
version = "0.1.0"
dependencies = [
"anyhow",
"editor",
"gpui",
"gpui_platform",
"language",
"multi_buffer",
"project",
"release_channel",
"semver",
"settings",
"theme",
"workspace",
]
[[package]]
name = "either"
version = "1.15.0"
+1
View File
@@ -61,6 +61,7 @@ members = [
"crates/edit_prediction_types",
"crates/edit_prediction_ui",
"crates/editor",
"crates/editor_benchmarks",
"crates/encoding_selector",
"crates/env_var",
"crates/etw_tracing",
+217 -31
View File
@@ -11,7 +11,7 @@ use anyhow::{Context as _, Result, anyhow};
use collections::{HashMap, HashSet};
use file_icons::FileIcons;
use fs::MTime;
use futures::future::try_join_all;
use futures::{channel::oneshot, future::try_join_all};
use git::status::GitSummary;
use gpui::{
AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font,
@@ -22,22 +22,24 @@ use language::{
SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
};
use lsp::DiagnosticSeverity;
use multi_buffer::{MultiBufferOffset, PathKey};
use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
use project::{
File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
project_settings::ProjectSettings, search::SearchQuery,
};
use rope::TextSummary;
use rpc::proto::{self, update_view};
use settings::Settings;
use std::{
any::{Any, TypeId},
borrow::Cow,
cmp::{self, Ordering},
num::NonZeroU32,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
};
use text::{BufferId, BufferSnapshot, Selection};
use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
use ui::{IconDecorationKind, prelude::*};
use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
@@ -1871,6 +1873,7 @@ impl SearchableItem for Editor {
ranges.iter().cloned().collect::<Vec<_>>()
});
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let mut ranges = Vec::new();
@@ -1879,38 +1882,70 @@ impl SearchableItem for Editor {
} else {
search_within_ranges
};
let num_cpus = executor.num_cpus();
for range in search_within_ranges {
for (search_buffer, search_range, deleted_hunk_anchor) in
buffer.range_to_buffer_ranges_with_deleted_hunks(range)
{
ranges.extend(
query
.search(
search_buffer,
Some(search_range.start.0..search_range.end.0),
)
.await
.into_iter()
.filter_map(|match_range| {
if let Some(deleted_hunk_anchor) = deleted_hunk_anchor {
let start = search_buffer
.anchor_after(search_range.start + match_range.start);
let end = search_buffer
.anchor_before(search_range.start + match_range.end);
Some(
deleted_hunk_anchor.with_diff_base_anchor(start)
..deleted_hunk_anchor.with_diff_base_anchor(end),
)
} else {
let start = search_buffer
.anchor_after(search_range.start + match_range.start);
let end = search_buffer
.anchor_before(search_range.start + match_range.end);
buffer.buffer_anchor_range_to_anchor_range(start..end)
}
}),
);
let query = query.clone();
let mut results = Vec::new();
executor
.scoped(|scope| {
for search_range in chunk_search_range(
search_buffer.text.clone(),
&query,
num_cpus as u32,
search_range,
) {
let query = query.clone();
let buffer = buffer.clone();
let (tx, rx) = oneshot::channel();
results.push(rx);
scope.spawn(async move {
let chunk_result = query
.search(
search_buffer,
Some(search_range.start..search_range.end),
)
.await
.into_iter()
.filter_map(|match_range| {
if let Some(deleted_hunk_anchor) = deleted_hunk_anchor {
let start = search_buffer.anchor_after(
search_range.start + match_range.start,
);
let end = search_buffer.anchor_before(
search_range.start + match_range.end,
);
Some(
deleted_hunk_anchor.with_diff_base_anchor(start)
..deleted_hunk_anchor
.with_diff_base_anchor(end),
)
} else {
let start = search_buffer.anchor_after(
search_range.start + match_range.start,
);
let end = search_buffer.anchor_before(
search_range.start + match_range.end,
);
buffer.anchor_range_in_buffer(start..end)
}
})
.collect::<Vec<_>>();
_ = tx.send(chunk_result);
});
}
})
.await;
for rx in results {
if let Ok(results) = rx.await {
ranges.extend(results);
}
}
}
}
@@ -2109,6 +2144,48 @@ fn deserialize_path_key(path_key: proto::PathKey) -> Option<PathKey> {
})
}
fn chunk_search_range(
buffer: BufferSnapshot,
query: &SearchQuery,
num_cpus: u32,
initial_range: Range<BufferOffset>,
) -> Box<dyn Iterator<Item = Range<usize>> + 'static> {
let range = initial_range.to_offset(&buffer);
if range.is_empty() {
return Box::new(std::iter::empty());
}
let summary: TextSummary = buffer.text_summary_for_range(initial_range);
let num_chunks = if !query.is_regex() && !query.as_str().contains('\n') {
NonZeroU32::new(summary.lines.row.saturating_add(1).min(num_cpus.max(1)))
} else {
NonZeroU32::new(1)
};
let Some(num_chunks) = num_chunks else {
return Box::new(std::iter::empty());
};
let mut chunk_start = range.start;
let rope = buffer.as_rope().clone();
let range_end = range.end;
let average_chunk_length = summary.len.div_ceil(num_chunks.get() as usize);
Box::new(std::iter::from_fn(move || {
if chunk_start >= range_end {
return None;
}
let candidate_position = chunk_start + average_chunk_length;
let adjusted = rope.ceil_char_boundary(candidate_position);
let mut as_point = rope.offset_to_point(adjusted);
as_point.row += 1;
as_point.column = 0;
let end_offset = buffer.point_to_offset(as_point).min(range_end);
let ret = chunk_start..end_offset;
chunk_start = end_offset;
Some(ret)
}))
}
#[cfg(test)]
mod tests {
use crate::editor_tests::init_test;
@@ -2134,6 +2211,115 @@ mod tests {
assert_eq!(path_for_file(&file, 0, false, cx), None);
}
#[gpui::test]
fn test_chunk_search_range_multi_line(cx: &mut App) {
let text = "line one\nline two\nline three\nline four\nline five\nline six\n";
let buffer = cx.new(|cx| Buffer::local(text, cx));
let snapshot = buffer.read(cx).snapshot();
let chunks = chunk_search_range_for_test(&snapshot, "line", 4, 0..text.len());
assert_chunks_are_contiguous(&chunks, 0..text.len());
assert!(
chunks.len() <= 4,
"got {} chunks, expected <= num_cpus (4)",
chunks.len()
);
for chunk in &chunks {
let end = chunk.end;
assert!(
end == text.len() || text.as_bytes()[end - 1] == b'\n',
"chunk ending at {end} is not a line boundary",
);
}
}
#[gpui::test]
fn test_chunk_search_range_single_line(cx: &mut App) {
let text = "hello world hello again";
let buffer = cx.new(|cx| Buffer::local(text, cx));
let snapshot = buffer.read(cx).snapshot();
let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 0..text.len());
assert_chunks_are_contiguous(&chunks, 0..text.len());
}
#[gpui::test]
fn test_chunk_search_range_empty_range(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("hello world", cx));
let snapshot = buffer.read(cx).snapshot();
let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 5..5);
assert!(chunks.is_empty());
}
#[gpui::test]
fn test_chunk_search_range_does_not_start_at_zero(cx: &mut App) {
let line = "abcdefghij\n";
let text = line.repeat(20);
let buffer = cx.new(|cx| Buffer::local(text.clone(), cx));
let snapshot = buffer.read(cx).snapshot();
let start = line.len() * 7;
let end = line.len() * 14;
let chunks = chunk_search_range_for_test(&snapshot, "abc", 4, start..end);
assert_chunks_are_contiguous(&chunks, start..end);
}
fn chunk_search_range_for_test(
snapshot: &language::BufferSnapshot,
query: &str,
num_cpus: u32,
range: Range<usize>,
) -> Vec<Range<usize>> {
let query = SearchQuery::text(
query,
false,
false,
false,
Default::default(),
Default::default(),
false,
None,
)
.unwrap();
chunk_search_range(
snapshot.text.clone(),
&query,
num_cpus,
BufferOffset(range.start)..BufferOffset(range.end),
)
.collect()
}
#[track_caller]
fn assert_chunks_are_contiguous(chunks: &[Range<usize>], expected: Range<usize>) {
assert!(!chunks.is_empty(), "expected at least one chunk");
assert_eq!(
chunks.first().unwrap().start,
expected.start,
"first chunk does not start at {}",
expected.start
);
assert_eq!(
chunks.last().unwrap().end,
expected.end,
"last chunk does not end at {}",
expected.end
);
for chunk in chunks {
assert!(chunk.start < chunk.end, "empty chunk: {:?}", chunk);
}
for window in chunks.windows(2) {
assert_eq!(
window[0].end, window[1].start,
"gap or overlap between chunks {:?} and {:?}",
window[0], window[1],
);
}
}
async fn deserialize_editor(
item_id: ItemId,
workspace_id: WorkspaceId,
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "editor_benchmarks"
version = "0.1.0"
publish.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
editor.workspace = true
gpui.workspace = true
gpui_platform.workspace = true
language.workspace = true
multi_buffer.workspace = true
project.workspace = true
release_channel.workspace = true
semver.workspace = true
settings.workspace = true
theme.workspace = true
workspace.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
+180
View File
@@ -0,0 +1,180 @@
use std::sync::Arc;
use editor::Editor;
use gpui::{AppContext as _, AsyncWindowContext, WeakEntity, WindowBounds, WindowOptions};
use language::Buffer;
use multi_buffer::Anchor;
use project::search::SearchQuery;
use workspace::searchable::SearchableItem;
#[derive(Debug)]
struct Args {
file: String,
query: String,
replace: Option<String>,
regex: bool,
whole_word: bool,
case_sensitive: bool,
}
fn parse_args() -> Args {
let mut args_iter = std::env::args().skip(1);
let mut parsed = Args {
file: String::new(),
query: String::new(),
replace: None,
regex: false,
whole_word: false,
case_sensitive: false,
};
let mut positional = Vec::new();
while let Some(arg) = args_iter.next() {
match arg.as_str() {
"--regex" => parsed.regex = true,
"--whole-word" => parsed.whole_word = true,
"--case-sensitive" => parsed.case_sensitive = true,
"-r" | "--replace" => {
parsed.replace = args_iter.next();
}
"--help" | "-h" => {
eprintln!(
"Usage: editor_benchmarks [OPTIONS] <FILE> <QUERY>\n\n\
Arguments:\n \
<FILE> Path to the file to search in\n \
<QUERY> The search query string\n\n\
Options:\n \
-r, --replace <TEXT> Replacement text (runs replace_all)\n \
--regex Treat query as regex\n \
--whole-word Match whole words only\n \
--case-sensitive Case-sensitive matching\n \
-h, --help Print help"
);
std::process::exit(0);
}
other => positional.push(other.to_string()),
}
}
if positional.len() < 2 {
eprintln!("Usage: editor_benchmarks [OPTIONS] <FILE> <QUERY>");
std::process::exit(1);
}
parsed.file = positional.remove(0);
parsed.query = positional.remove(0);
parsed
}
fn main() {
let args = parse_args();
let file_contents = std::fs::read_to_string(&args.file).expect("failed to read input file");
let file_len = file_contents.len();
println!("Read {} ({file_len} bytes)", args.file);
let mut query = if args.regex {
SearchQuery::regex(
&args.query,
args.whole_word,
args.case_sensitive,
false,
false,
Default::default(),
Default::default(),
false,
None,
)
.expect("invalid regex query")
} else {
SearchQuery::text(
&args.query,
args.whole_word,
args.case_sensitive,
false,
Default::default(),
Default::default(),
false,
None,
)
.expect("invalid text query")
};
if let Some(replacement) = args.replace.as_deref() {
query = query.with_replacement(replacement.to_string());
}
let query = Arc::new(query);
let has_replacement = args.replace.is_some();
gpui_platform::headless().run(move |cx| {
release_channel::init_test(
semver::Version::new(0, 0, 0),
release_channel::ReleaseChannel::Dev,
cx,
);
settings::init(cx);
theme::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
let buffer = cx.new(|cx| Buffer::local(file_contents, cx));
let window_handle = cx
.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(gpui::Bounds {
origin: Default::default(),
size: gpui::size(gpui::px(800.0), gpui::px(600.0)),
})),
focus: false,
show: false,
..Default::default()
},
|window, cx| cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)),
)
.expect("failed to open window");
window_handle
.update(cx, move |_, window, cx| {
cx.spawn_in(
window,
async move |weak: WeakEntity<Editor>,
cx: &mut AsyncWindowContext|
-> anyhow::Result<()> {
let find_task = weak.update_in(cx, |editor, window, cx| {
editor.find_matches(query.clone(), window, cx)
})?;
println!("Finding matches...");
let timer = std::time::Instant::now();
let matches: Vec<std::ops::Range<Anchor>> = find_task.await;
let find_elapsed = timer.elapsed();
println!("Found {} matches in {find_elapsed:?}", matches.len());
if has_replacement && !matches.is_empty() {
window_handle.update(cx, |editor: &mut Editor, window, cx| {
let mut match_iter = matches.iter();
println!("Replacing all matches...");
let timer = std::time::Instant::now();
editor.replace_all(
&mut match_iter,
&query,
Default::default(),
window,
cx,
);
let replace_elapsed = timer.elapsed();
println!(
"Replaced {} matches in {replace_elapsed:?}",
matches.len()
);
})?;
}
std::process::exit(0);
},
)
.detach();
})
.unwrap();
});
}
+25 -1
View File
@@ -5258,6 +5258,16 @@ impl MultiBufferSnapshot {
Some(Anchor::in_buffer(path_key_index, anchor))
}
/// Lifts a buffer anchor range to a multibuffer anchor range without checking against excerpt boundaries. Returns `None` if there are no excerpts for the buffer.
pub fn anchor_range_in_buffer(&self, range: Range<text::Anchor>) -> Option<Range<Anchor>> {
if range.start.buffer_id != range.end.buffer_id {
return None;
}
let path_key_index = self.path_key_index_for_buffer(range.start.buffer_id)?;
Some(Anchor::range_in_buffer(path_key_index, range))
}
/// Creates a multibuffer anchor for the given buffer anchor, if it is contained in any excerpt.
pub fn anchor_in_excerpt(&self, text_anchor: text::Anchor) -> Option<Anchor> {
let excerpts = {
@@ -5295,6 +5305,19 @@ impl MultiBufferSnapshot {
&self,
text_anchor: Range<text::Anchor>,
) -> Option<Range<Anchor>> {
if self.is_singleton() {
let excerpt = self.excerpts.first()?;
let buffer_snapshot = excerpt.buffer_snapshot(self);
if excerpt.range.contains(&text_anchor.start, &buffer_snapshot)
&& excerpt.range.contains(&text_anchor.end, &buffer_snapshot)
{
return Some(Anchor::range_in_buffer(excerpt.path_key_index, text_anchor));
}
}
// for each search match
let mut buffer_snapshot = None;
for excerpt in {
let this = &self;
let buffer_id = text_anchor.start.buffer_id;
@@ -5316,7 +5339,8 @@ impl MultiBufferSnapshot {
.into_iter()
.flatten()
} {
let buffer_snapshot = excerpt.buffer_snapshot(self);
let buffer_snapshot =
buffer_snapshot.get_or_insert_with(|| excerpt.buffer_snapshot(self));
if excerpt.range.contains(&text_anchor.start, &buffer_snapshot)
&& excerpt.range.contains(&text_anchor.end, &buffer_snapshot)
{
+42 -7
View File
@@ -38,7 +38,7 @@ use std::{
};
pub use subscription::*;
pub use sum_tree::Bias;
use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet};
use sum_tree::{Dimensions, FilterCursor, SumTree, Summary, TreeMap, TreeSet};
use undo_map::UndoMap;
use util::debug_panic;
@@ -912,7 +912,8 @@ impl Buffer {
let mut new_ropes =
RopeBuilder::new(self.visible_text.cursor(0), self.deleted_text.cursor(0));
let mut old_fragments = self.fragments.cursor::<FragmentTextSummary>(&None);
let mut new_fragments = old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right);
let mut new_fragments =
FragmentBuilder::new(old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right));
new_ropes.append(new_fragments.summary().text);
let mut fragment_start = old_fragments.start().visible;
@@ -1044,7 +1045,7 @@ impl Buffer {
let (visible_text, deleted_text) = new_ropes.finish();
drop(old_fragments);
self.snapshot.fragments = new_fragments;
self.snapshot.fragments = new_fragments.to_sum_tree(&None);
self.snapshot.insertions.edit(new_insertions, ());
self.snapshot.visible_text = visible_text;
self.snapshot.deleted_text = deleted_text;
@@ -1127,8 +1128,9 @@ impl Buffer {
let mut old_fragments = self
.fragments
.cursor::<Dimensions<VersionedFullOffset, usize>>(&cx);
let mut new_fragments =
old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left);
let mut new_fragments = FragmentBuilder::new(
old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left),
);
new_ropes.append(new_fragments.summary().text);
let mut fragment_start = old_fragments.start().0.full_offset();
@@ -1291,7 +1293,7 @@ impl Buffer {
let (visible_text, deleted_text) = new_ropes.finish();
drop(old_fragments);
self.snapshot.fragments = new_fragments;
self.snapshot.fragments = new_fragments.to_sum_tree(&None);
self.snapshot.visible_text = visible_text;
self.snapshot.deleted_text = deleted_text;
self.snapshot.insertions.edit(new_insertions, ());
@@ -1303,7 +1305,7 @@ impl Buffer {
new_text: &str,
timestamp: clock::Lamport,
insertion_offset: &mut u32,
new_fragments: &mut SumTree<Fragment>,
new_fragments: &mut FragmentBuilder,
new_insertions: &mut Vec<sum_tree::Edit<InsertionFragment>>,
insertion_slices: &mut Vec<InsertionSlice>,
new_ropes: &mut RopeBuilder,
@@ -2836,6 +2838,39 @@ impl BufferSnapshot {
}
}
struct FragmentBuilder {
fragments: Vec<Fragment>,
summary: FragmentSummary,
}
impl FragmentBuilder {
fn new(init: SumTree<Fragment>) -> Self {
Self {
summary: init.summary().clone(),
fragments: init.iter().cloned().collect(),
}
}
fn append(&mut self, items: SumTree<Fragment>, cx: &Option<clock::Global>) {
if !items.is_empty() {
self.summary.add_summary(items.summary(), cx);
self.fragments.extend(items.iter().cloned());
}
}
fn push(&mut self, fragment: Fragment, cx: &Option<clock::Global>) {
self.append(SumTree::from_item(fragment, cx), cx);
}
fn to_sum_tree(self, cx: &Option<clock::Global>) -> SumTree<Fragment> {
if self.fragments.len() > 1024 {
SumTree::from_par_iter(self.fragments, cx)
} else {
SumTree::from_iter(self.fragments, cx)
}
}
fn summary(&self) -> &FragmentSummary {
&self.summary
}
}
struct RopeBuilder<'a> {
old_visible_cursor: rope::Cursor<'a>,
old_deleted_cursor: rope::Cursor<'a>,