editor: Speed up multi cursor editing (#58510)

Early draft for #32051 (multi-cursor editing is very slow, and basically
hangs at high cursor counts). Opening it early like @Anthony-Eid
suggested so we can agree on direction before I go further.

## Root cause

Typing with a cursor on every line is mostly O(N)-per-keystroke work
spread across a few places. I profiled it with `sample` and per-phase
timers around `handle_input`. At 1000 cursors (~24ms before this PR):
the post-edit display-map sync is ~11.8ms, the CRDT edit
(`apply_local_edit`) ~3.7ms, resolving all N selections plus the
per-cursor input loop ~3.5ms, the post-edit selection round-trip ~2.6ms,
and `change_selections` plus transact machinery ~2.4ms. The display sync
re-runs per-edit `SumTree` work through every layer even when nothing
transforms (the plain-text case), and selections get re-resolved through
the full display round-trip several times per keystroke.

## What this does

- Display fast paths: `InlayMap::sync` (no inlays) and
`WrapMap::interpolate` (no soft-wrap) skip the O(edits) transform-tree
rebuild and return the passthrough snapshot, gated so a pending inlay
splice or existing wraps still take the slow path.
- Selection fast path: when nothing collapses buffer content
(`!has_folds() && !has_replacement_blocks()`), resolve `Anchor` to
`Point` to `Offset` batched and skip the per-selection display
round-trip (the `todo(lw)`).
- Render and autoscroll resolve only the first/last/newest selections
instead of all N per frame.
- A parameterized `Multi-cursor input/cursors/{1000,10000,100000}`
benchmark.

## Results

- Typing (`handle_input`): ~2.1x faster (24ms to 11.7ms at 1k).
- Type plus two word-deletes (criterion): 29% faster (89.9 to 63.8ms) at
1k, 37% (959 to 606ms) at 10k.
- Post-edit display sync alone: 11.8 to 3.8ms at 1k, 133 to 45ms at 10k.

All `editor` (759), `display_map` (67), `multi_buffer` (58), and `text`
(36) tests pass, and `./script/clippy` is clean.

## Direction

This is ~2x, and I can get the current architecture to roughly 3.5-4.5x
with a few more safe changes (hoisting the per-cursor
language/editability checks, cheaper snapshot clones). Genuine VS Code
numbers (~1-2us/cursor) aren't reachable while the buffer is a CRDT rope
of fragments with anchor selections and a 5-layer transform stack. That
would need a plain-offset cursor model and/or decoupling display layout
from the edit path, which is a bigger effort I'd want to design with
you. One thing I'd need your call on: `set_active_selections` sends an
`UpdateSelections` collab op every keystroke (building N anchors); can
that be debounced to transaction end, or do presence/follow-mode
features rely on per-keystroke cursor broadcast?

Release Notes:

- editor: Improved multi cursor editing performance

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
This commit is contained in:
Rani 2026-06-17 10:57:05 +03:00 committed by GitHub
parent 45afbac0a5
commit 1722fe63bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 574 additions and 171 deletions

View file

@ -21,7 +21,7 @@ criterion.workspace = true
editor = { workspace = true, features = ["test-support"] }
futures.workspace = true
gpui = { workspace = true, features = ["bench"] }
gpui_platform = { workspace = true, features = ["test-support"] }
gpui_platform = { workspace = true, features = ["test-support", "font-kit"] }
itertools.workspace = true
language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }

View file

@ -1,19 +1,23 @@
use criterion::{Bencher, BenchmarkId};
use editor::{
Editor, EditorMode, MultiBuffer,
actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines},
};
use gpui::{AppContext as _, BenchAppContext, Focusable as _, TestAppContext, TestDispatcher};
use gpui::{AppContext as _, BenchAppContext, Focusable as _};
use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
use settings::SettingsStore;
use util::RandomCharIter;
use zed_actions::editor::{MoveDown, MoveUp};
#[gpui::bench]
fn editor_input_with_1000_cursors(cx: &mut BenchAppContext) {
#[gpui::bench(
inputs = multi_cursor_line_counts(),
group = "Multi-cursor input",
input_name = "cursors",
sample_size = 10
)]
fn editor_multi_cursor_input(line_count: &usize, cx: &mut BenchAppContext) {
init_context(cx);
let text = String::from_iter(["line:\n"; 1000]);
let text = "line:\n".repeat(*line_count);
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
let mut window = cx.add_empty_window();
@ -60,15 +64,16 @@ fn editor_input_with_1000_cursors(cx: &mut BenchAppContext) {
});
}
fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, TestAppContext)) {
let (text, cx) = args;
let mut cx = cx.clone();
#[gpui::bench]
fn open_editor_with_one_long_line(cx: &mut BenchAppContext) {
init_context(cx);
bencher.iter(|| {
let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx));
let text = String::from_iter(["char"; 1000]);
cx.bench_iter(move |cx| {
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
let cx = cx.add_empty_window();
cx.update(|window, cx| {
let mut window = cx.add_empty_window();
window.update(|window, cx| {
let editor = cx.new(|cx| {
let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx);
editor.set_style(editor::EditorStyle::default(), window, cx);
@ -129,36 +134,18 @@ fn init_context(cx: &mut BenchAppContext) {
});
}
fn init_test_context(cx: &TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
assets::Assets.load_test_fonts(cx);
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
});
}
fn criterion_benches(criterion: &mut criterion::Criterion) {
let dispatcher = TestDispatcher::new(1);
let cx = gpui::TestAppContext::build(dispatcher, None);
init_test_context(&cx);
let text = String::from_iter(["char"; 1000]);
let input = (text, cx);
let mut group = criterion.benchmark_group("Build buffer with one long line");
group.bench_with_input(
BenchmarkId::new("editor_with_one_long_line", "(String, TestAppContext )"),
&input,
open_editor_with_one_long_line,
);
group.finish();
fn multi_cursor_line_counts() -> Vec<usize> {
let mut line_counts = vec![1000, 10000];
if std::env::var("ZED_BENCH_HUGE").is_ok() {
line_counts.push(100000);
}
line_counts
}
gpui::bench_group!(
benches,
editor_input_with_1000_cursors,
editor_render,
criterion_benches
editor_multi_cursor_input,
open_editor_with_one_long_line,
editor_render
);
gpui::bench_main!(benches);

View file

@ -1547,6 +1547,11 @@ impl DisplaySnapshot {
&self.block_snapshot.wrap_snapshot.tab_snapshot.fold_snapshot
}
#[inline(always)]
pub fn has_collapsed_content(&self) -> bool {
self.fold_snapshot().has_folds() || self.block_snapshot.has_replacement_blocks()
}
pub fn inlay_snapshot(&self) -> &InlaySnapshot {
&self
.block_snapshot

View file

@ -331,6 +331,8 @@ impl std::fmt::Display for BlockId {
#[derive(Clone, Debug)]
struct Transform {
summary: TransformSummary,
/// When `block` is `None`, the transform is isomorphic and passes input
/// wrap rows through as normal text.
block: Option<Block>,
}
@ -510,6 +512,7 @@ struct TransformSummary {
output_rows: BlockRow,
longest_row: BlockRow,
longest_row_chars: u32,
has_replacement_blocks: bool,
}
pub struct BlockChunks<'a> {
@ -1089,6 +1092,7 @@ impl BlockMap {
output_rows: BlockRow(block.height()),
longest_row: BlockRow(0),
longest_row_chars: 0,
has_replacement_blocks: false,
};
let rows_before_block;
@ -1599,6 +1603,7 @@ fn push_isomorphic(tree: &mut SumTree<Transform>, rows: RowDelta, wrap_snapshot:
output_rows: BlockRow(rows.0),
longest_row: BlockRow(wrap_summary.longest_row),
longest_row_chars: wrap_summary.longest_row_chars,
has_replacement_blocks: false,
};
let mut merged = false;
tree.update_last(
@ -2145,6 +2150,11 @@ impl BlockMapWriter<'_> {
}
impl BlockSnapshot {
#[inline(always)]
pub fn has_replacement_blocks(&self) -> bool {
self.transforms.summary().has_replacement_blocks
}
#[cfg(test)]
#[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
@ -2755,7 +2765,9 @@ impl sum_tree::Item for Transform {
type Summary = TransformSummary;
fn summary(&self, _cx: ()) -> Self::Summary {
self.summary.clone()
let mut summary = self.summary.clone();
summary.has_replacement_blocks = self.block.as_ref().is_some_and(Block::is_replacement);
summary
}
}
@ -2771,6 +2783,7 @@ impl sum_tree::ContextLessSummary for TransformSummary {
}
self.input_rows += summary.input_rows;
self.output_rows += summary.output_rows;
self.has_replacement_blocks |= summary.has_replacement_blocks;
}
}

View file

@ -722,6 +722,11 @@ impl FoldSnapshot {
self.folds.items(&self.inlay_snapshot.buffer).len()
}
#[inline(always)]
pub fn has_folds(&self) -> bool {
!self.folds.is_empty()
}
#[ztracing::instrument(skip_all)]
pub fn text_summary_for_range(&self, range: Range<FoldPoint>) -> MBTextSummary {
let mut summary = MBTextSummary::default();

View file

@ -82,6 +82,12 @@ struct TransformSummary {
output: MBTextSummary,
}
impl TransformSummary {
fn has_inlays(&self) -> bool {
self.input.len != self.output.len
}
}
impl sum_tree::ContextLessSummary for TransformSummary {
fn zero() -> Self {
Default::default()
@ -593,6 +599,29 @@ impl InlayMap {
snapshot.buffer = buffer_snapshot;
(snapshot.clone(), Vec::new())
} else if self.inlays.is_empty() && !snapshot.transforms.summary().has_inlays() {
// Fast path: without inlays, the InlayMap is a passthrough, so rebuild a single
// isomorphic transform and forward buffer edits as inlay edits verbatim.
let mut new_transforms = SumTree::default();
push_isomorphic(&mut new_transforms, buffer_snapshot.text_summary());
if new_transforms.is_empty() {
new_transforms.push(Transform::Isomorphic(Default::default()), ());
}
let mut inlay_edits = Patch::default();
for buffer_edit in &buffer_edits {
inlay_edits.push(Edit {
old: InlayOffset(buffer_edit.old.start)..InlayOffset(buffer_edit.old.end),
new: InlayOffset(buffer_edit.new.start)..InlayOffset(buffer_edit.new.end),
});
}
snapshot.transforms = new_transforms;
snapshot.version += 1;
snapshot.buffer = buffer_snapshot;
snapshot.check_invariants();
(snapshot.clone(), inlay_edits.into_inner())
} else {
let mut inlay_edits = Patch::default();
let mut new_transforms = SumTree::default();

View file

@ -66,6 +66,12 @@ struct TransformSummary {
output: TextSummary,
}
impl TransformSummary {
fn has_wraps(&self) -> bool {
self.input.lines != self.output.lines
}
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct WrapPoint(pub Point);
@ -386,6 +392,12 @@ impl WrapSnapshot {
let mut new_transforms;
if tab_edits.is_empty() {
new_transforms = self.transforms.clone();
} else if !self.transforms.summary().has_wraps()
&& !new_tab_snapshot.text_summary().lines.is_zero()
{
// Fast path: without existing wraps, interpolation is a passthrough over the new tab snapshot.
new_transforms = SumTree::default();
new_transforms.push(Transform::isomorphic(new_tab_snapshot.text_summary()), ());
} else {
let mut old_cursor = self.transforms.cursor::<TabPoint>(());

View file

@ -8256,30 +8256,31 @@ impl Element for EditorElement {
.editor_with_selections(cx)
.map(|editor| {
editor.update(cx, |editor, cx| {
let all_selections =
editor.selections.all::<Point>(&snapshot.display_snapshot);
let all_anchor_selections =
editor.selections.all_anchors(&snapshot.display_snapshot);
let selected_buffer_ids =
if editor.buffer_kind(cx) == ItemBufferKind::Singleton {
Vec::new()
} else {
let mut selected_buffer_ids =
Vec::with_capacity(all_selections.len());
let is_singleton =
editor.buffer_kind(cx) == ItemBufferKind::Singleton;
for selection in all_selections {
for buffer_id in snapshot
.buffer_snapshot()
.buffer_ids_for_range(selection.range())
{
if selected_buffer_ids.last() != Some(&buffer_id) {
selected_buffer_ids.push(buffer_id);
}
// Singleton buffers only need the newest selection anchor here.
let selected_buffer_ids = if is_singleton {
Vec::new()
} else {
let all_selections =
editor.selections.all::<Point>(&snapshot.display_snapshot);
let mut selected_buffer_ids =
Vec::with_capacity(all_selections.len());
for selection in all_selections {
for buffer_id in snapshot
.buffer_snapshot()
.buffer_ids_for_range(selection.range())
{
if selected_buffer_ids.last() != Some(&buffer_id) {
selected_buffer_ids.push(buffer_id);
}
}
}
selected_buffer_ids
};
selected_buffer_ids
};
let mut selections = editor.selections.disjoint_in_range(
start_anchor..end_anchor,
@ -8288,28 +8289,45 @@ impl Element for EditorElement {
selections
.extend(editor.selections.pending(&snapshot.display_snapshot));
let mut anchors_by_buffer: HashMap<BufferId, (usize, Anchor)> =
HashMap::default();
for selection in all_anchor_selections.iter() {
let head = selection.head();
if let Some((text_anchor, _)) =
snapshot.buffer_snapshot().anchor_to_buffer_anchor(head)
{
let latest_selection_anchors: HashMap<BufferId, Anchor> =
if is_singleton {
let head = editor.selections.newest_anchor().head();
snapshot
.buffer_snapshot()
.anchor_to_buffer_anchor(head)
.map(|(text_anchor, _)| (text_anchor.buffer_id, head))
.into_iter()
.collect()
} else {
let all_anchor_selections = editor
.selections
.all_anchors(&snapshot.display_snapshot);
let mut anchors_by_buffer: HashMap<
BufferId,
(usize, Anchor),
> = HashMap::default();
for selection in all_anchor_selections.iter() {
let head = selection.head();
if let Some((text_anchor, _)) = snapshot
.buffer_snapshot()
.anchor_to_buffer_anchor(head)
{
anchors_by_buffer
.entry(text_anchor.buffer_id)
.and_modify(|(latest_id, latest_anchor)| {
if selection.id > *latest_id {
*latest_id = selection.id;
*latest_anchor = head;
}
})
.or_insert((selection.id, head));
}
}
anchors_by_buffer
.entry(text_anchor.buffer_id)
.and_modify(|(latest_id, latest_anchor)| {
if selection.id > *latest_id {
*latest_id = selection.id;
*latest_anchor = head;
}
})
.or_insert((selection.id, head));
}
}
let latest_selection_anchors = anchors_by_buffer
.into_iter()
.map(|(buffer_id, (_, anchor))| (buffer_id, anchor))
.collect();
.into_iter()
.map(|(buffer_id, (_, anchor))| (buffer_id, anchor))
.collect()
};
(selections, selected_buffer_ids, latest_selection_anchors)
})

View file

@ -171,17 +171,16 @@ impl Editor {
target_top = target_point.row().as_f64();
target_bottom = target_top + 1.;
} else {
let selections = self.selections.all::<Point>(&display_map);
target_point = selections
.first()
.unwrap()
// Autoscroll only needs the first, last, and newest selections.
target_point = self
.selections
.first::<Point>(&display_map)
.head()
.to_display_point(&display_map);
target_top = target_point.row().as_f64();
target_bottom = selections
.last()
.unwrap()
target_bottom = self
.selections
.last::<Point>(&display_map)
.head()
.to_display_point(&display_map)
.row()
@ -195,10 +194,9 @@ impl Editor {
) || (matches!(autoscroll, Autoscroll::Strategy(AutoscrollStrategy::Fit, _))
&& !selections_fit)
{
target_point = selections
.iter()
.max_by_key(|s| s.id)
.unwrap()
target_point = self
.selections
.newest::<Point>(&display_map)
.head()
.to_display_point(&display_map);
target_top = target_point.row().as_f64();
@ -361,7 +359,18 @@ impl Editor {
let scroll_width = ScrollOffset::from(scroll_width);
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let selections = self.selections.all::<Point>(&display_map);
// Horizontal autoscroll only needs selections whose head is visible.
let visible_end_row = DisplayRow(start_row.0 + layouts.len() as u32);
let visible_range = display_map
.buffer_snapshot()
.anchor_before(DisplayPoint::new(start_row, 0).to_offset(&display_map, Bias::Left))
..display_map.buffer_snapshot().anchor_after(
DisplayPoint::new(visible_end_row, 0).to_offset(&display_map, Bias::Right),
);
let mut selections = self
.selections
.disjoint_in_range::<Point>(visible_range, &display_map);
selections.extend(self.selections.pending::<Point>(&display_map));
let mut scroll_position = self.scroll_manager.scroll_position(&display_map, cx);
let mut target_left;

View file

@ -5,7 +5,7 @@ use std::{
};
use gpui::Pixels;
use itertools::Itertools as _;
use itertools::{Either, Itertools as _};
use language::{Bias, Point, PointUtf16, Selection, SelectionGoal};
use multi_buffer::{MultiBufferDimension, MultiBufferOffset};
use util::post_inc;
@ -123,11 +123,19 @@ impl SelectionsCollection {
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
{
let disjoint_anchors = &self.disjoint;
self.all_iter(snapshot).collect()
}
fn all_iter<'a, D>(
&'a self,
snapshot: &'a DisplaySnapshot,
) -> impl 'a + Iterator<Item = Selection<D>>
where
D: 'a + MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
{
let mut disjoint =
resolve_selections_wrapping_blocks::<D, _>(disjoint_anchors.iter(), &snapshot)
.peekable();
let mut pending_opt = self.pending::<D>(&snapshot);
resolve_selections_wrapping_blocks::<D, _>(self.disjoint.iter(), snapshot).peekable();
let mut pending_opt = self.pending::<D>(snapshot);
iter::from_fn(move || {
if let Some(pending) = pending_opt.as_mut() {
while let Some(next_selection) = disjoint.peek() {
@ -157,7 +165,6 @@ impl SelectionsCollection {
disjoint.next()
}
})
.collect()
}
/// Returns all of the selections, adjusted to take into account the selection line_mode
@ -317,14 +324,30 @@ impl SelectionsCollection {
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
{
self.all(snapshot).first().unwrap().clone()
// Without a pending selection, the first disjoint selection is also the first resolved
// selection, so avoid resolving every selection.
if self.pending.is_none()
&& let Some(first) = self.disjoint.first()
{
return resolve_selections_wrapping_blocks([first], snapshot)
.next()
.unwrap();
}
self.all_iter(snapshot).next().unwrap()
}
pub fn last<D>(&self, snapshot: &DisplaySnapshot) -> Selection<D>
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
{
self.all(snapshot).last().unwrap().clone()
if self.pending.is_none()
&& let Some(last) = self.disjoint.last()
{
return resolve_selections_wrapping_blocks([last], snapshot)
.next()
.unwrap();
}
self.all_iter(snapshot).last().unwrap()
}
/// Returns a list of (potentially backwards!) ranges representing the selections.
@ -1172,6 +1195,51 @@ pub(crate) fn resolve_selections_wrapping_blocks<'a, D, I>(
selections: I,
map: &'a DisplaySnapshot,
) -> impl 'a + Iterator<Item = Selection<D>>
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
I: 'a + IntoIterator<Item = &'a Selection<Anchor>>,
{
// Without collapsed content, coalescing in buffer point space is equivalent to coalescing in
// display point space, so skip the per-selection display-coordinate round trip.
if !map.has_collapsed_content() {
Either::Left(resolve_selections_without_display_round_trip(
selections, map,
))
} else {
Either::Right(resolve_selections_via_display_round_trip(selections, map))
}
}
fn resolve_selections_without_display_round_trip<'a, D, I>(
selections: I,
map: &'a DisplaySnapshot,
) -> impl 'a + Iterator<Item = Selection<D>>
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
I: 'a + IntoIterator<Item = &'a Selection<Anchor>>,
{
let (to_convert, selections) =
coalesce_selections(resolve_selections_point(selections, map)).tee();
let mut converted_endpoints = map
.buffer_snapshot()
.dimensions_from_points::<D>(to_convert.flat_map(|s| [s.start, s.end]));
selections.map(move |s| {
let start = converted_endpoints.next().unwrap();
let end = converted_endpoints.next().unwrap();
Selection {
id: s.id,
start,
end,
reversed: s.reversed,
goal: s.goal,
}
})
}
fn resolve_selections_via_display_round_trip<'a, D, I>(
selections: I,
map: &'a DisplaySnapshot,
) -> impl 'a + Iterator<Item = Selection<D>>
where
D: MultiBufferDimension + Sub + AddAssign<<D as Sub>::Output> + Ord,
I: 'a + IntoIterator<Item = &'a Selection<Anchor>>,
@ -1268,3 +1336,135 @@ fn should_merge<T: Ord + Copy>(a_start: T, a_end: T, b_start: T, b_end: T, sorte
is_overlapping || same_start || cursor_at_boundary
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
MultiBuffer,
display_map::{BlockPlacement, BlockProperties, BlockStyle, DisplayMap, FoldPlaceholder},
test::test_font,
};
use gpui::{AppContext as _, IntoElement as _, div, px};
use project::project_settings::DiagnosticSeverity;
use rand::{Rng as _, rngs::StdRng};
use settings::SettingsStore;
use std::sync::Arc;
#[gpui::test(iterations = 20)]
fn fast_and_slow_selection_resolution_match_without_collapsed_content(
cx: &mut gpui::TestAppContext,
mut rng: StdRng,
) {
cx.update(|cx| {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
crate::init(cx);
});
let text = random_text(&mut rng);
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
let display_map = cx.new(|cx| {
DisplayMap::new(
buffer.clone(),
test_font(),
px(14.),
Some(px(rng.random_range(80.0..=180.0))),
1,
1,
FoldPlaceholder::test(),
DiagnosticSeverity::Warning,
cx,
)
});
let snapshot = display_map.update(cx, |map, cx| {
let buffer_snapshot = buffer.read(cx).snapshot(cx);
map.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Above(
buffer_snapshot.anchor_after(MultiBufferOffset(text.len() / 2)),
),
height: Some(2),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any_element()),
priority: 0,
}],
cx,
);
map.snapshot(cx)
});
assert!(!snapshot.has_collapsed_content());
let selections = random_anchor_selections(&snapshot, text.len(), &mut rng);
let fast_points =
resolve_selections_without_display_round_trip::<Point, _>(selections.iter(), &snapshot)
.collect::<Vec<_>>();
let slow_points =
resolve_selections_via_display_round_trip::<Point, _>(selections.iter(), &snapshot)
.collect::<Vec<_>>();
assert_eq!(fast_points, slow_points);
let fast_offsets = resolve_selections_without_display_round_trip::<MultiBufferOffset, _>(
selections.iter(),
&snapshot,
)
.collect::<Vec<_>>();
let slow_offsets = resolve_selections_via_display_round_trip::<MultiBufferOffset, _>(
selections.iter(),
&snapshot,
)
.collect::<Vec<_>>();
assert_eq!(fast_offsets, slow_offsets);
}
fn random_text(rng: &mut StdRng) -> String {
let mut text = String::new();
for line in 0..rng.random_range(8..32) {
if line > 0 {
text.push('\n');
}
for _ in 0..rng.random_range(8..48) {
let ch = match rng.random_range(0..12) {
0 => '\t',
1 => ' ',
_ => rng.random_range(b'a'..=b'z') as char,
};
text.push(ch);
}
}
text
}
fn random_anchor_selections(
snapshot: &DisplaySnapshot,
text_len: usize,
rng: &mut StdRng,
) -> Vec<Selection<Anchor>> {
let buffer = snapshot.buffer_snapshot();
let mut selections = Vec::new();
let mut offset = 0;
for id in 0..rng.random_range(1..32) {
if offset > text_len {
break;
}
let start = rng.random_range(offset..=text_len);
let end = rng.random_range(start..=text_len);
selections.push(Selection {
id,
start: MultiBufferOffset(start),
end: MultiBufferOffset(end),
reversed: rng.random(),
goal: SelectionGoal::None,
});
offset = end.saturating_add(rng.random_range(0..=4));
}
selections
.into_iter()
.map(|selection| selection_to_anchor_selection(selection, buffer))
.collect()
}
}

View file

@ -11,56 +11,51 @@ use hdrhistogram::Histogram;
use crate::{
AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BenchDispatcher,
Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global,
NoopTextSystem, Platform, PlatformHeadlessRenderer, Render, Reservation, Task, TestPlatform,
Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global, Platform,
PlatformHeadlessRenderer, PlatformTextSystem, Render, Reservation, Task, TestPlatform,
VisualContext, Window, WindowBounds, WindowHandle, WindowOptions,
app::GpuiBorrow,
profiler::{self, FrameTiming, FrameTimingCollector},
};
/// Returns this thread's shared benchmark platform, creating it on first use.
/// Returns a benchmark platform backed by this thread's shared dispatcher.
///
/// The platform is a [`TestPlatform`] backed by a multithreaded
/// [`BenchDispatcher`], so background work runs with production concurrency in
/// real time. It is cached per thread and reused across benchmark invocations
/// so worker and timer threads persist for the whole process instead of being
/// recreated for every Criterion calibration pass.
/// The platform uses this thread's shared multithreaded [`BenchDispatcher`], so
/// background work runs with production concurrency in real time. The dispatcher
/// is cached per thread and reused across benchmark invocations so worker and
/// timer threads persist for the whole process instead of being recreated for
/// every Criterion calibration pass.
///
/// Text is shaped with [`NoopTextSystem`] (one glyph per character at fixed
/// advances). This keeps results deterministic across machines and font
/// installations while preserving the structure of downstream layout and paint
/// work; absolute timings exclude production text shaping (roughly 10% of draw
/// time for a full editor frame). Benchmarks that need real shaping can build
/// a [`TestPlatform`] with a platform text system and pass it to
/// [`BenchAppContext::new_with_platform_and_report`].
/// Text is shaped with the provided platform text system. Benchmarks generated
/// by `#[gpui::bench]` use the current platform's text system, so text-heavy
/// benchmark measurements include production shaping and glyph rasterization.
///
/// `headless_renderer_factory` (only used on first call) supplies a renderer
/// for benchmark windows, e.g. `gpui_platform::current_headless_renderer`.
/// When present, scenes drawn by benchmarks are rasterized through the real
/// sprite atlas and submitted to the GPU on present, so quad/sprite
/// regressions show up in measurements. When `None`, presenting discards the
/// scene. Currently only macOS provides a headless renderer (Metal), so GPU
/// submission is excluded from benchmark measurements on other platforms.
/// `headless_renderer_factory` supplies a renderer for benchmark windows, e.g.
/// `gpui_platform::current_headless_renderer`. When present, scenes drawn by
/// benchmarks are rasterized through the real sprite atlas and submitted to
/// the GPU on present, so quad/sprite regressions show up in measurements.
/// When `None`, presenting discards the scene. Currently only macOS provides
/// a headless renderer (Metal), so GPU submission is excluded from benchmark
/// measurements on other platforms.
pub fn bench_platform(
headless_renderer_factory: Option<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
text_system: Arc<dyn PlatformTextSystem>,
) -> Rc<dyn Platform> {
thread_local! {
static PLATFORM: OnceCell<Rc<TestPlatform>> = const { OnceCell::new() };
static DISPATCHER: OnceCell<Arc<BenchDispatcher>> = const { OnceCell::new() };
}
PLATFORM.with(|cell| {
cell.get_or_init(|| {
let dispatcher = Arc::new(BenchDispatcher::new());
let background_executor = BackgroundExecutor::new(dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(dispatcher);
TestPlatform::with_platform(
background_executor,
foreground_executor,
Arc::new(NoopTextSystem::new()),
headless_renderer_factory,
)
})
.clone() as Rc<dyn Platform>
})
let dispatcher = DISPATCHER.with(|cell| {
cell.get_or_init(|| Arc::new(BenchDispatcher::new()))
.clone()
});
let background_executor = BackgroundExecutor::new(dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(dispatcher);
TestPlatform::with_platform(
background_executor,
foreground_executor,
text_system,
headless_renderer_factory,
) as Rc<dyn Platform>
}
/// Default target frame rate when a benchmark doesn't specify `fps = N`.
@ -442,9 +437,12 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> {
/// Adds a window with an empty root view for benchmark setup.
pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> {
let bounds = {
let app = self.app.borrow();
Bounds::maximized(None, &app)
};
let window = {
let mut app = self.app.borrow_mut();
let bounds = Bounds::maximized(None, &app);
let window: AnyWindowHandle = app
.open_window(
WindowOptions {
@ -481,20 +479,34 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> {
/// Runs GPUI benchmark teardown.
///
/// Forgets any timers still armed on the shared dispatcher so they can't
/// fire during a later benchmark; assumes no other `BenchAppContext` is
/// live on this thread.
/// Cancels any timers still armed on the shared dispatcher and drains the
/// work that cancellation unblocks so they can't fire during a later
/// benchmark; assumes no other `BenchAppContext` is live on this thread.
pub fn teardown(mut self) {
self.run_until_idle();
self.update(|cx| {
cx.quit();
});
self.run_until_idle();
self.background_executor
.dispatcher()
let dispatcher = self.background_executor.dispatcher();
let dispatcher = dispatcher
.as_bench()
.expect("validated in BenchAppContext::build")
.forget_pending_timers();
.expect("validated in BenchAppContext::build");
drop(self.app);
drop(self.foreground_executor);
for _ in 0..100 {
if dispatcher.cancel_pending_timers() == 0 {
return;
}
dispatcher.run_until_idle();
}
panic!(
"benchmark teardown kept scheduling timers: {}",
dispatcher.debug_state()
);
}
}

View file

@ -258,16 +258,34 @@ impl BenchDispatcher {
}
}
/// Forgets all pending timers so timers armed by one benchmark can't fire
/// Cancels all pending timers so timers armed by one benchmark can't fire
/// during a later benchmark sharing this process-lifetime dispatcher.
///
/// The runnables are leaked rather than dropped, since dropping one wakes
/// the awaiting task as if the timer had fired.
pub fn forget_pending_timers(&self) {
let mut state = self.timers.state.lock();
for entry in state.heap.drain() {
std::mem::forget(entry.runnable);
}
/// Dropping a timer runnable drops its completion sender, waking the task
/// awaiting the timer. Call [`Self::run_until_idle`] after this method to
/// drain any work that cancellation unblocks.
pub fn cancel_pending_timers(&self) -> usize {
let timers = {
let mut state = self.timers.state.lock();
let timers: Vec<_> = state.heap.drain().collect();
self.timers.condvar.notify_all();
timers
};
let canceled = timers.len();
drop(timers);
canceled
}
/// Describes the dispatcher's idle-tracking state, for diagnosing
/// benchmarks that fail to reach quiescence.
pub fn debug_state(&self) -> String {
let inflight = *self.idle.inflight.lock();
let timers = self.timers.state.lock().heap.len();
let main_queue_has_work = self.main_queue_has_work();
format!(
"BenchDispatcher {{ inflight: {inflight}, pending_timers: {timers}, \
main_queue_has_work: {main_queue_has_work} }}"
)
}
fn has_due_timer(&self) -> bool {
@ -415,12 +433,12 @@ mod tests {
}
#[test]
fn forget_pending_timers_prevents_stale_timers_from_firing() {
fn cancel_pending_timers_wakes_waiters_without_waiting_for_deadline() {
let dispatcher = Arc::new(BenchDispatcher::new());
let background = BackgroundExecutor::new(dispatcher.clone());
let fired = Arc::new(AtomicBool::new(false));
let timer = background.timer(Duration::from_millis(250));
let timer = background.timer(Duration::from_secs(10));
background
.spawn({
let fired = fired.clone();
@ -432,10 +450,10 @@ mod tests {
.detach();
dispatcher.run_until_idle();
dispatcher.forget_pending_timers();
thread::sleep(Duration::from_millis(400));
assert_eq!(dispatcher.cancel_pending_timers(), 1);
dispatcher.run_until_idle();
assert!(!fired.load(Ordering::SeqCst));
assert!(fired.load(Ordering::SeqCst));
assert_eq!(dispatcher.cancel_pending_timers(), 0);
}
}

View file

@ -1,9 +1,13 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{ItemFn, parse::Parser, spanned::Spanned};
use syn::{Expr, ItemFn, LitStr, parse::Parser, spanned::Spanned};
pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream {
let mut fps: Option<u64> = None;
let mut inputs: Option<Expr> = None;
let mut input_name: Option<LitStr> = None;
let mut group_name: Option<LitStr> = None;
let mut sample_size: Option<usize> = None;
if !args.is_empty() {
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("fps") {
@ -14,8 +18,29 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream {
}
fps = Some(value);
Ok(())
} else if meta.path.is_ident("inputs") {
inputs = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("input_name") {
input_name = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("group") {
group_name = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("sample_size") {
let value: syn::LitInt = meta.value()?.parse()?;
let value = value.base10_parse::<usize>()?;
if value == 0 {
return Err(
meta.error("#[gpui::bench] `sample_size` must be greater than zero")
);
}
sample_size = Some(value);
Ok(())
} else {
Err(meta.error("#[gpui::bench] only accepts `fps = N`"))
Err(meta.error(
"#[gpui::bench] only accepts `fps = N`, `inputs = EXPR`, `input_name = \"...\"`, `group = \"...\"`, and `sample_size = N`",
))
}
});
if let Err(error) = parser.parse(args) {
@ -46,18 +71,75 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream {
let inner_fn_name = format_ident!("__gpui_bench_{}", outer_fn_name);
inner_fn.sig.ident = inner_fn_name.clone();
TokenStream::from(quote! {
#inner_fn
fn #outer_fn_name(criterion: &mut criterion::Criterion) {
let benchmark = if let Some(inputs) = inputs {
let input_name = match input_name {
Some(input_name) => quote! { #input_name },
None => quote! { stringify!(#outer_fn_name) },
};
let group_name = match group_name {
Some(group_name) => quote! { #group_name },
None => quote! { stringify!(#outer_fn_name) },
};
let sample_size =
sample_size.map(|sample_size| quote! { group.sample_size(#sample_size); });
quote! {
let report = #report_expr;
let mut group = criterion.benchmark_group(#group_name);
#sample_size
for input in #inputs {
group.bench_with_input(criterion::BenchmarkId::new(#input_name, &input), &input, {
let report = report.clone();
move |bencher, input| {
let mut cx = gpui::BenchAppContext::new_with_platform_and_report(
gpui::bench_platform(
Some(Box::new(|| {
gpui_platform::current_headless_renderer()
})),
gpui_platform::current_platform(true).text_system(),
),
Some(stringify!(#outer_fn_name)),
bencher,
report.clone(),
);
#inner_fn_name(input, &mut cx);
cx.teardown();
}
});
}
group.finish();
report.print(Some(stringify!(#outer_fn_name)));
}
} else {
if let Some(input_name) = input_name {
return error_to_stream(syn::Error::new(
input_name.span(),
"#[gpui::bench] `input_name` requires `inputs`",
));
}
if let Some(group_name) = group_name {
return error_to_stream(syn::Error::new(
group_name.span(),
"#[gpui::bench] `group` requires `inputs`",
));
}
if sample_size.is_some() {
return error_to_stream(syn::Error::new(
proc_macro2::Span::call_site(),
"#[gpui::bench] `sample_size` requires `inputs`",
));
}
quote! {
let report = #report_expr;
criterion.bench_function(stringify!(#outer_fn_name), {
let report = report.clone();
move |bencher| {
let mut cx = gpui::BenchAppContext::new_with_platform_and_report(
gpui::bench_platform(Some(Box::new(|| {
gpui_platform::current_headless_renderer()
}))),
gpui::bench_platform(
Some(Box::new(|| {
gpui_platform::current_headless_renderer()
})),
gpui_platform::current_platform(true).text_system(),
),
Some(stringify!(#outer_fn_name)),
bencher,
report.clone(),
@ -68,6 +150,14 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream {
});
report.print(Some(stringify!(#outer_fn_name)));
}
};
TokenStream::from(quote! {
#inner_fn
fn #outer_fn_name(criterion: &mut criterion::Criterion) {
#benchmark
}
})
}

View file

@ -192,6 +192,11 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
/// `#[gpui::bench]` annotates a Criterion benchmark that runs with GPUI support.
///
/// Use `#[gpui::bench(inputs = some_iterable())]` on benchmarks that take an
/// additional input argument; the generated benchmark uses Criterion's
/// `bench_with_input`. `group`, `input_name`, and `sample_size` can customize
/// the generated input benchmark group.
///
/// The benchmark crate must add `criterion` and `gpui_platform` (with its
/// `test-support` feature) to its dev-dependencies and enable gpui's `bench`
/// feature, since the generated code references all three.