perf: Add initial benchmark for markdown element (#59524)

## Summary

This PR adds an initial markdown element renderer benchmark, so we could
later expand this to benchmark search in markdown, and reparsing. This
is important because the agent panel renders a lot of markdown elements,
so I want to use these benchmarks to ensure our markdown element
performance is good, and later expand them to include the agent panel

I added a `bench_util.rs` file that has methods to generate random rust
modules, this will later be used by the editor benchmarks, and is
currently used by the markdown and edit_file_tool benchmarks.

Finally, I made `cx.bench_renderer` also account for ready foreground
tasks and poll them. This more accurately mimics gpui dispatcher. (It's
not perfect, but it's good enough for a benchmark)


## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Anant Goel <anant@zed.dev>
This commit is contained in:
Anthony Eid 2026-06-17 22:47:10 -04:00 committed by GitHub
parent 770d5a8aa3
commit 6076ce2738
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 505 additions and 73 deletions

1
Cargo.lock generated
View file

@ -2151,6 +2151,7 @@ dependencies = [
"language",
"language_model",
"lsp",
"markdown",
"multi_buffer",
"project",
"prompt_store",

View file

@ -12,6 +12,9 @@ doctest = false
[lints]
workspace = true
[dependencies]
rand.workspace = true
[dev-dependencies]
action_log.workspace = true
agent = { workspace = true, features = ["test-support"] }
@ -26,10 +29,10 @@ itertools.workspace = true
language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
markdown.workspace = true
multi_buffer.workspace = true
project = { workspace = true, features = ["test-support"] }
prompt_store.workspace = true
rand.workspace = true
serde_json.workspace = true
settings = { workspace = true, features = ["test-support"] }
text.workspace = true
@ -50,3 +53,7 @@ harness = false
[[bench]]
name = "edit_file_tool"
harness = false
[[bench]]
name = "markdown_renderer"
harness = false

View file

@ -12,6 +12,10 @@ use agent::{
Templates, Thread, ToolCallEventStream, ToolInput,
};
use agent_settings::{AgentSettings, ToolRules};
use benchmarks::bench_utils::{
RUST_FUNCTION_BODY_LINES, RUST_FUNCTION_LINES, RUST_MODULE_HEADER_LINES, random_rust_file,
rust_file_line_count, rust_identifier as identifier,
};
use criterion::{
BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main,
};
@ -478,7 +482,7 @@ fn make_fixture(
seed: u64,
) -> EditFixture {
let mut rng = StdRng::seed_from_u64(seed);
let old_lines = random_rust_module(&mut rng, function_count);
let old_lines = random_rust_file(&mut rng, rust_file_line_count(function_count));
let edit_range = edit_range(&old_lines, &pattern);
let old_text = old_lines[edit_range.clone()].join("\n");
let mut new_lines = old_lines.clone();
@ -514,12 +518,8 @@ fn make_large_multi_edit_fixture(
edit_count: usize,
seed: u64,
) -> EditFixture {
const HEADER_LINES: usize = 10;
const FUNCTION_LINES: usize = 12;
const FUNCTION_BODY_LINES: usize = 11;
let mut rng = StdRng::seed_from_u64(seed);
let old_lines = random_rust_module(&mut rng, function_count);
let old_lines = random_rust_file(&mut rng, rust_file_line_count(function_count));
let old_file_text = old_lines.join("\n");
let step = (function_count / edit_count).max(1);
@ -541,8 +541,8 @@ fn make_large_multi_edit_fixture(
let edits = replacements
.iter()
.map(|(function_index, new_function)| {
let start = HEADER_LINES + function_index * FUNCTION_LINES;
let end = start + FUNCTION_BODY_LINES;
let start = RUST_MODULE_HEADER_LINES + function_index * RUST_FUNCTION_LINES;
let end = start + RUST_FUNCTION_BODY_LINES;
EditOp {
old_text: old_lines[start..end].join("\n"),
new_text: new_function.join("\n"),
@ -552,8 +552,8 @@ fn make_large_multi_edit_fixture(
let mut new_lines = old_lines;
for (function_index, new_function) in replacements.iter().rev() {
let start = HEADER_LINES + function_index * FUNCTION_LINES;
let end = start + FUNCTION_BODY_LINES;
let start = RUST_MODULE_HEADER_LINES + function_index * RUST_FUNCTION_LINES;
let end = start + RUST_FUNCTION_BODY_LINES;
new_lines.splice(start..end, new_function.iter().cloned());
}
let expected_file_text = new_lines.join("\n");
@ -622,56 +622,6 @@ fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range<usize>
range
}
fn random_rust_module(rng: &mut StdRng, function_count: usize) -> Vec<String> {
let mut lines = vec![
"use anyhow::{Context as _, Result};".to_string(),
"use collections::HashMap;".to_string(),
"".to_string(),
"#[derive(Clone, Debug)]".to_string(),
"pub struct WorkspaceSnapshot {".to_string(),
" buffers: HashMap<String, usize>,".to_string(),
" version: usize,".to_string(),
"}".to_string(),
"".to_string(),
"impl WorkspaceSnapshot {".to_string(),
];
for function_index in 0..function_count {
let function_name = identifier(rng, function_index);
let argument_name = identifier(rng, function_index + 1_000);
let local_name = identifier(rng, function_index + 2_000);
let branch_name = identifier(rng, function_index + 3_000);
let multiplier = rng.random_range(2..17);
let offset = rng.random_range(1..128);
lines.extend([
format!(
" pub fn {function_name}(&mut self, {argument_name}: usize) -> Result<usize> {{"
),
format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"),
format!(" if {local_name} % 2 == 0 {{"),
format!(
" {local_name} = {local_name}.saturating_add(self.version + {offset});"
),
" } else {".to_string(),
format!(" {local_name} = {local_name}.saturating_sub({offset});"),
" }".to_string(),
format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"),
format!(" self.version = self.version.saturating_add({branch_name});"),
format!(" Ok({branch_name})"),
" }".to_string(),
"".to_string(),
]);
}
lines.push("}".to_string());
lines.push("".to_string());
lines.push("pub fn normalize_path(path: &str) -> String {".to_string());
lines.push(" path.replace('\\\\', \"/\")".to_string());
lines.push("}".to_string());
lines
}
fn rewrite_local_block(lines: &mut [String], rng: &mut StdRng) {
for (line_index, line) in lines.iter_mut().enumerate() {
let suffix = identifier(rng, line_index + 10_000);
@ -727,17 +677,5 @@ fn insert_helper_blocks(
}
}
fn identifier(rng: &mut StdRng, salt: usize) -> String {
const PARTS: &[&str] = &[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "theta", "lambda", "sigma", "omega",
];
format!(
"{}_{}_{}",
PARTS[rng.random_range(0..PARTS.len())],
salt,
rng.random_range(0..10_000)
)
}
criterion_group!(benches, edit_file_tool_streaming);
criterion_main!(benches);

View file

@ -0,0 +1,348 @@
use benchmarks::bench_utils::random_rust_file;
use gpui::{
AppContext as _, BenchAppContext, Context, Entity, IntoElement, Render, SharedString, Window,
};
use language::LanguageRegistry;
use markdown::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont,
MarkdownOptions, MarkdownStyle, WrapButtonVisibility,
};
use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
use settings::SettingsStore;
use std::sync::Arc;
use ui::prelude::*;
const SEED: u64 = 1;
#[gpui::bench(
inputs = markdown_sizes(),
group = "Markdown render",
input_name = "min_bytes",
sample_size = 10
)]
fn markdown_render(target_size: &usize, cx: &mut BenchAppContext) {
init_context(cx);
let source = SharedString::from(generate_markdown(SEED, *target_size));
let language_registry = markdown_language_registry(cx);
let mut window = cx.add_empty_window();
let view = window.update(|window, cx| {
let markdown = cx.new({
let source = source.clone();
let language_registry = language_registry.clone();
move |cx| build_markdown(source, language_registry, cx)
});
window.replace_root(cx, |_window, _cx| MarkdownBenchView { markdown })
});
cx.bench_renderer(view, |_, _, cx| cx.notify());
}
struct MarkdownBenchView {
markdown: Entity<Markdown>,
}
impl Render for MarkdownBenchView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let style = MarkdownStyle::themed(MarkdownFont::Preview, window, cx);
div().w_full().h_full().child(
MarkdownElement::new(self.markdown.clone(), style).code_block_renderer(
CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
wrap_button_visibility: WrapButtonVisibility::VisibleOnHover,
border: false,
},
),
)
}
}
fn build_markdown(
source: SharedString,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Markdown>,
) -> Markdown {
Markdown::new_with_options(
source,
Some(language_registry),
None,
MarkdownOptions {
// Mermaid and embedded resources need their own focused benchmarks.
render_metadata_blocks: true,
..Default::default()
},
cx,
)
}
fn generate_markdown(seed: u64, target_size: usize) -> String {
let mut rng = StdRng::seed_from_u64(seed);
let mut markdown = String::with_capacity(target_size);
markdown.push_str("---\n");
markdown.push_str("title: Markdown renderer benchmark\n");
markdown.push_str("author: Zed benchmark\n");
markdown.push_str("---\n\n");
while markdown.len() < target_size {
push_mixed_block(&mut markdown, &mut rng);
}
markdown
}
fn push_mixed_block(markdown: &mut String, rng: &mut StdRng) {
match rng.random_range(0..10) {
0 => push_heading(markdown, rng),
1 | 2 => push_paragraph(markdown, rng),
3 => push_list(markdown, rng),
4 => push_task_list(markdown, rng),
5 => push_table(markdown, rng),
6 | 7 => push_code_block(markdown, rng),
8 => push_block_quote(markdown, rng),
_ => push_rule(markdown),
}
}
fn push_heading(markdown: &mut String, rng: &mut StdRng) {
let level = rng.random_range(1..=4);
markdown.push_str(&"#".repeat(level));
markdown.push(' ');
let word_count = rng.random_range(3..8);
push_words(markdown, rng, word_count);
markdown.push_str("\n\n");
}
fn push_paragraph(markdown: &mut String, rng: &mut StdRng) {
let sentence_count = rng.random_range(2..7);
for sentence_index in 0..sentence_count {
if sentence_index > 0 {
markdown.push(' ');
}
push_sentence(markdown, rng);
}
markdown.push_str("\n\n");
}
fn push_sentence(markdown: &mut String, rng: &mut StdRng) {
let word_count = rng.random_range(8..24);
for word_index in 0..word_count {
if word_index > 0 {
markdown.push(' ');
}
match rng.random_range(0..18) {
0 => {
markdown.push_str("**");
markdown.push_str(random_word(rng));
markdown.push_str("**");
}
1 => {
markdown.push('*');
markdown.push_str(random_word(rng));
markdown.push('*');
}
2 => {
markdown.push('`');
markdown.push_str(random_identifier(rng));
markdown.push('`');
}
3 => {
markdown.push('[');
markdown.push_str(random_word(rng));
markdown.push_str("](https://example.com/");
markdown.push_str(random_identifier(rng));
markdown.push(')');
}
4 => {
markdown.push_str("https://zed.dev/");
markdown.push_str(random_identifier(rng));
}
_ => markdown.push_str(random_word(rng)),
}
}
markdown.push('.');
}
fn push_list(markdown: &mut String, rng: &mut StdRng) {
let item_count = rng.random_range(3..9);
let ordered = rng.random();
for item_index in 0..item_count {
if ordered {
markdown.push_str(&(item_index + 1).to_string());
markdown.push_str(". ");
} else {
markdown.push_str("- ");
}
let word_count = rng.random_range(5..14);
push_words(markdown, rng, word_count);
markdown.push('\n');
}
markdown.push('\n');
}
fn push_task_list(markdown: &mut String, rng: &mut StdRng) {
let item_count = rng.random_range(3..8);
for _ in 0..item_count {
if rng.random() {
markdown.push_str("- [x] ");
} else {
markdown.push_str("- [ ] ");
}
let word_count = rng.random_range(4..12);
push_words(markdown, rng, word_count);
markdown.push('\n');
}
markdown.push('\n');
}
fn push_table(markdown: &mut String, rng: &mut StdRng) {
let column_count = rng.random_range(3..7);
let row_count = rng.random_range(3..9);
for column_index in 0..column_count {
if column_index == 0 {
markdown.push('|');
}
markdown.push(' ');
markdown.push_str(random_word(rng));
markdown.push(' ');
markdown.push('|');
}
markdown.push('\n');
for column_index in 0..column_count {
if column_index == 0 {
markdown.push('|');
}
markdown.push_str(" --- |");
}
markdown.push('\n');
for _ in 0..row_count {
for column_index in 0..column_count {
if column_index == 0 {
markdown.push('|');
}
markdown.push(' ');
let word_count = rng.random_range(2..6);
push_words(markdown, rng, word_count);
markdown.push(' ');
markdown.push('|');
}
markdown.push('\n');
}
markdown.push('\n');
}
fn push_code_block(markdown: &mut String, rng: &mut StdRng) {
let line_count = rng.random_range(24..80);
let rust = random_rust_file(rng, line_count);
markdown.push_str("```rust\n");
markdown.push_str(&rust.join("\n"));
markdown.push_str("\n```\n\n");
}
fn push_block_quote(markdown: &mut String, rng: &mut StdRng) {
let line_count = rng.random_range(2..6);
for _ in 0..line_count {
markdown.push_str("> ");
push_sentence(markdown, rng);
markdown.push('\n');
}
markdown.push('\n');
}
fn push_rule(markdown: &mut String) {
markdown.push_str("---\n\n");
}
fn push_words(markdown: &mut String, rng: &mut StdRng, word_count: usize) {
for word_index in 0..word_count {
if word_index > 0 {
markdown.push(' ');
}
markdown.push_str(random_word(rng));
}
}
fn random_word(rng: &mut StdRng) -> &'static str {
const WORDS: &[&str] = &[
"renderer",
"layout",
"markdown",
"paragraph",
"heading",
"table",
"selection",
"syntax",
"highlight",
"window",
"element",
"callback",
"benchmark",
"profile",
"latency",
"throughput",
"scroll",
"wrapping",
"theme",
"language",
"fenced",
"blockquote",
"inline",
"content",
];
choose(rng, WORDS)
}
fn random_identifier(rng: &mut StdRng) -> &'static str {
const IDENTIFIERS: &[&str] = &[
"markdown_renderer",
"layout_cache",
"rendered_text",
"source_range",
"bench_input",
"window_state",
"code_block",
"table_row",
"link_target",
"scroll_handle",
"text_style",
"root_block",
];
choose(rng, IDENTIFIERS)
}
fn choose(rng: &mut StdRng, items: &'static [&'static str]) -> &'static str {
let index = rng.random_range(0..items.len());
items.get(index).copied().unwrap_or("markdown")
}
fn markdown_language_registry(cx: &BenchAppContext) -> Arc<LanguageRegistry> {
let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
registry.add(language::rust_lang());
registry
}
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);
});
}
fn markdown_sizes() -> Vec<usize> {
let mut sizes = vec![5_000, 10_000, 50_000, 250_000];
if std::env::var("ZED_BENCH_HUGE").is_ok() {
sizes.push(1_000_000);
}
sizes
}
gpui::bench_group!(benches, markdown_render);
gpui::bench_main!(benches);

View file

@ -0,0 +1,89 @@
use rand::Rng;
pub const RUST_MODULE_HEADER_LINES: usize = 10;
pub const RUST_FUNCTION_LINES: usize = 12;
pub const RUST_FUNCTION_BODY_LINES: usize = 11;
pub const RUST_MODULE_FOOTER_LINES: usize = 5;
pub fn rust_file_line_count(function_count: usize) -> usize {
RUST_MODULE_HEADER_LINES + function_count * RUST_FUNCTION_LINES + RUST_MODULE_FOOTER_LINES
}
pub fn random_rust_file(rng: &mut impl Rng, line_count: usize) -> Vec<String> {
if line_count < RUST_MODULE_HEADER_LINES + RUST_MODULE_FOOTER_LINES {
return (0..line_count)
.map(|line_index| format!("// generated benchmark line {line_index}"))
.collect();
}
let mut lines = vec![
"use anyhow::{Context as _, Result};".to_string(),
"use collections::HashMap;".to_string(),
"".to_string(),
"#[derive(Clone, Debug)]".to_string(),
"pub struct WorkspaceSnapshot {".to_string(),
" buffers: HashMap<String, usize>,".to_string(),
" version: usize,".to_string(),
"}".to_string(),
"".to_string(),
"impl WorkspaceSnapshot {".to_string(),
];
let body_line_count = line_count - RUST_MODULE_HEADER_LINES - RUST_MODULE_FOOTER_LINES;
let function_count = body_line_count / RUST_FUNCTION_LINES;
let filler_line_count = body_line_count % RUST_FUNCTION_LINES;
for function_index in 0..function_count {
let function_name = rust_identifier(rng, function_index);
let argument_name = rust_identifier(rng, function_index + 1_000);
let local_name = rust_identifier(rng, function_index + 2_000);
let branch_name = rust_identifier(rng, function_index + 3_000);
let multiplier = rng.random_range(2..17);
let offset = rng.random_range(1..128);
lines.extend([
format!(
" pub fn {function_name}(&mut self, {argument_name}: usize) -> Result<usize> {{"
),
format!(" let mut {local_name} = {argument_name}.saturating_mul({multiplier});"),
format!(" if {local_name} % 2 == 0 {{"),
format!(
" {local_name} = {local_name}.saturating_add(self.version + {offset});"
),
" } else {".to_string(),
format!(" {local_name} = {local_name}.saturating_sub({offset});"),
" }".to_string(),
format!(" let {branch_name} = self.buffers.len().saturating_add({local_name});"),
format!(" self.version = self.version.saturating_add({branch_name});"),
format!(" Ok({branch_name})"),
" }".to_string(),
"".to_string(),
]);
}
for filler_index in 0..filler_line_count {
let filler_name = rust_identifier(rng, function_count + 4_000 + filler_index);
lines.push(format!(" // benchmark filler {filler_name}"));
}
lines.push("}".to_string());
lines.push("".to_string());
lines.push("pub fn normalize_path(path: &str) -> String {".to_string());
lines.push(" path.replace('\\\\', \"/\")".to_string());
lines.push("}".to_string());
debug_assert_eq!(lines.len(), line_count);
lines
}
pub fn rust_identifier(rng: &mut impl Rng, salt: usize) -> String {
const PARTS: &[&str] = &[
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "theta", "lambda", "sigma", "omega",
];
format!(
"{}_{}_{}",
PARTS[rng.random_range(0..PARTS.len())],
salt,
rng.random_range(0..10_000)
)
}

View file

@ -4,3 +4,5 @@
//! (Criterion, `gpui_platform`, gpui's `bench` feature, ...) don't weigh down
//! the test builds of the crates being benchmarked. Each file in `benches/`
//! targets one area of the codebase.
pub mod bench_utils;

View file

@ -409,9 +409,14 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> {
})
.expect("cannot benchmark renderer for entity without a current window");
let dispatcher = self.background_executor.dispatcher().clone();
let collector = FrameTraceScope::start();
let mut benchmark = || {
dispatcher
.as_bench()
.expect("validated in BenchAppContext::build")
.run_ready_main_tasks();
self.with_window(view.entity_id(), |window, cx| {
view.update(cx, |view, cx| update(view, window, cx));
})

View file

@ -258,6 +258,16 @@ impl BenchDispatcher {
}
}
/// Runs all main-thread tasks that are queued right now, without waiting for
/// background work or timers to finish.
pub fn run_ready_main_tasks(&self) -> bool {
assert!(
self.is_main_thread(),
"run_ready_main_tasks must be called on the benchmark main thread"
);
self.drain_main_queue()
}
/// Cancels all pending timers so timers armed by one benchmark can't fire
/// during a later benchmark sharing this process-lifetime dispatcher.
///
@ -379,6 +389,38 @@ mod tests {
use super::*;
use crate::{BackgroundExecutor, ForegroundExecutor};
#[test]
fn run_ready_main_tasks_does_not_wait_for_background_handoffs() {
let dispatcher = Arc::new(BenchDispatcher::new());
let background = BackgroundExecutor::new(dispatcher.clone());
let foreground = ForegroundExecutor::new(dispatcher.clone());
let (sender, receiver) = futures::channel::oneshot::channel();
background
.spawn(async move {
thread::sleep(Duration::from_millis(10));
sender.send(()).ok();
})
.detach();
let completed = Arc::new(AtomicBool::new(false));
foreground
.spawn({
let completed = completed.clone();
async move {
receiver.await.ok();
completed.store(true, Ordering::SeqCst);
}
})
.detach();
assert!(dispatcher.run_ready_main_tasks());
assert!(!completed.load(Ordering::SeqCst));
dispatcher.run_until_idle();
assert!(completed.load(Ordering::SeqCst));
}
#[test]
fn run_until_idle_completes_background_to_main_handoffs() {
let dispatcher = Arc::new(BenchDispatcher::new());