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>
152 lines
4.6 KiB
Rust
152 lines
4.6 KiB
Rust
use editor::{
|
|
Editor, EditorMode, MultiBuffer,
|
|
actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines},
|
|
};
|
|
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(
|
|
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 = "line:\n".repeat(*line_count);
|
|
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
|
|
|
|
let mut window = cx.add_empty_window();
|
|
let editor = 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);
|
|
editor.select_all(&SelectAll, window, cx);
|
|
editor.split_selection_into_lines(
|
|
&SplitSelectionIntoLines {
|
|
keep_selections: true,
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
editor
|
|
});
|
|
window.focus(&editor.focus_handle(cx), cx);
|
|
editor
|
|
});
|
|
|
|
cx.bench_iter(|_| {
|
|
window.update(|window, cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
editor.handle_input("hello world", window, cx);
|
|
editor.delete_to_previous_word_start(
|
|
&DeleteToPreviousWordStart {
|
|
ignore_newlines: false,
|
|
ignore_brackets: false,
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
editor.delete_to_previous_word_start(
|
|
&DeleteToPreviousWordStart {
|
|
ignore_newlines: false,
|
|
ignore_brackets: false,
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
})
|
|
});
|
|
}
|
|
|
|
#[gpui::bench]
|
|
fn open_editor_with_one_long_line(cx: &mut BenchAppContext) {
|
|
init_context(cx);
|
|
|
|
let text = String::from_iter(["char"; 1000]);
|
|
cx.bench_iter(move |cx| {
|
|
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, 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);
|
|
editor
|
|
});
|
|
window.focus(&editor.focus_handle(cx), cx);
|
|
editor
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::bench]
|
|
fn editor_render(cx: &mut BenchAppContext) {
|
|
init_context(cx);
|
|
|
|
let buffer = cx.update(|cx| {
|
|
let mut rng = StdRng::seed_from_u64(1);
|
|
let text_len = rng.random_range(10000..90000);
|
|
if rng.random() {
|
|
let text = RandomCharIter::new(&mut rng)
|
|
.take(text_len)
|
|
.collect::<String>();
|
|
MultiBuffer::build_simple(&text, cx)
|
|
} else {
|
|
MultiBuffer::build_random(&mut rng, cx)
|
|
}
|
|
});
|
|
|
|
let mut window = cx.add_empty_window();
|
|
let editor = window.update(|window, cx| {
|
|
let editor = window.replace_root(cx, |window, cx| {
|
|
let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx);
|
|
editor.set_style(editor::EditorStyle::default(), window, cx);
|
|
editor
|
|
});
|
|
window.focus(&editor.focus_handle(cx), cx);
|
|
editor
|
|
});
|
|
|
|
let mut move_down = true;
|
|
cx.bench_renderer(editor, move |editor, window, cx| {
|
|
if move_down {
|
|
editor.move_down(&MoveDown, window, cx);
|
|
} else {
|
|
editor.move_up(&MoveUp, window, cx);
|
|
}
|
|
move_down = !move_down;
|
|
});
|
|
}
|
|
|
|
fn init_context(cx: &mut BenchAppContext) {
|
|
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 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_multi_cursor_input,
|
|
open_editor_with_one_long_line,
|
|
editor_render
|
|
);
|
|
gpui::bench_main!(benches);
|