diff --git a/Cargo.lock b/Cargo.lock index 04adbe2026..b1273f3895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,7 +243,6 @@ dependencies = [ "cloud_llm_client", "collections", "context_server", - "criterion", "ctor", "db", "editor", @@ -2123,6 +2122,37 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "benchmarks" +version = "0.1.0" +dependencies = [ + "action_log", + "agent", + "agent_settings", + "assets", + "criterion", + "editor", + "futures 0.3.32", + "gpui", + "gpui_platform", + "itertools 0.14.0", + "language", + "language_model", + "lsp", + "multi_buffer", + "project", + "prompt_store", + "rand 0.9.4", + "serde_json", + "settings", + "text", + "theme", + "theme_settings", + "ui", + "util", + "zed_actions", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -5682,7 +5712,6 @@ dependencies = [ "clock", "collections", "convert_case 0.11.0", - "criterion", "ctor", "dap", "db", @@ -7706,6 +7735,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", + "criterion", "ctor", "derive_more", "embed-resource", diff --git a/Cargo.toml b/Cargo.toml index cdb78d78dd..0c9fb4d0b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/auto_update_ui", "crates/aws_http_client", "crates/bedrock", + "crates/benchmarks", "crates/breadcrumbs", "crates/buffer_diff", "crates/call", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 4e9647be10..3dd3f10633 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -84,7 +84,6 @@ agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } -criterion.workspace = true ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } @@ -110,8 +109,3 @@ theme_settings.workspace = true unindent = { workspace = true } zlog.workspace = true - -[[bench]] -name = "edit_file_tool" -harness = false -required-features = ["test-support"] diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml new file mode 100644 index 0000000000..20f980a881 --- /dev/null +++ b/crates/benchmarks/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "benchmarks" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/benchmarks.rs" +doctest = false + +[lints] +workspace = true + +[dev-dependencies] +action_log.workspace = true +agent = { workspace = true, features = ["test-support"] } +agent_settings.workspace = true +assets.workspace = true +criterion.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +gpui = { workspace = true, features = ["bench"] } +gpui_platform = { workspace = true, features = ["test-support"] } +itertools.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +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 +theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true +ui.workspace = true +util = { workspace = true, features = ["test-support"] } +zed_actions.workspace = true + +[[bench]] +name = "editor_render" +harness = false + +[[bench]] +name = "display_map" +harness = false + +[[bench]] +name = "edit_file_tool" +harness = false diff --git a/crates/benchmarks/LICENSE-GPL b/crates/benchmarks/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/benchmarks/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/editor/benches/display_map.rs b/crates/benchmarks/benches/display_map.rs similarity index 100% rename from crates/editor/benches/display_map.rs rename to crates/benchmarks/benches/display_map.rs diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/benchmarks/benches/edit_file_tool.rs similarity index 100% rename from crates/agent/benches/edit_file_tool.rs rename to crates/benchmarks/benches/edit_file_tool.rs diff --git a/crates/editor/benches/editor_render.rs b/crates/benchmarks/benches/editor_render.rs similarity index 80% rename from crates/editor/benches/editor_render.rs rename to crates/benchmarks/benches/editor_render.rs index 2840d782bd..91885fa74a 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/benchmarks/benches/editor_render.rs @@ -6,18 +6,18 @@ use editor::{ use gpui::{AppContext as _, BenchAppContext, Focusable as _, TestAppContext, TestDispatcher}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use settings::SettingsStore; -use ui::IntoElement; use util::RandomCharIter; +use zed_actions::editor::{MoveDown, MoveUp}; #[gpui::bench] -fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppContext) { +fn editor_input_with_1000_cursors(cx: &mut BenchAppContext) { init_context(cx); let text = String::from_iter(["line:\n"; 1000]); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); - let mut cx = cx.add_empty_window(); - let editor = cx.update(|window, 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); @@ -35,8 +35,8 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppCo editor }); - bencher.iter(|| { - cx.update(|window, cx| { + 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( @@ -80,8 +80,10 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes }); } -fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { - let mut cx = cx.clone(); +#[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); @@ -95,9 +97,9 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { } }); - let cx = cx.add_empty_window(); - let editor = cx.update(|window, cx| { - let editor = cx.new(|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 @@ -106,14 +108,15 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor }); - bencher.iter(|| { - cx.update(|window, cx| { - let mut view = editor.clone().into_any_element(); - let _ = view.request_layout(window, cx); - let _ = view.prepaint(window, cx); - view.paint(window, cx); - }); - }) + 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) { @@ -141,16 +144,8 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { let cx = gpui::TestAppContext::build(dispatcher, None); init_test_context(&cx); - let mut group = criterion.benchmark_group("Time to render"); - group.bench_with_input( - BenchmarkId::new("editor_render", "TestAppContext"), - &cx, - editor_render, - ); - group.finish(); - let text = String::from_iter(["char"; 1000]); - let input = (text, cx.clone()); + 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 )"), @@ -160,5 +155,10 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { group.finish(); } -gpui::bench_group!(benches, editor_input_with_1000_cursors, criterion_benches); +gpui::bench_group!( + benches, + editor_input_with_1000_cursors, + editor_render, + criterion_benches +); gpui::bench_main!(benches); diff --git a/crates/benchmarks/src/benchmarks.rs b/crates/benchmarks/src/benchmarks.rs new file mode 100644 index 0000000000..1b60630505 --- /dev/null +++ b/crates/benchmarks/src/benchmarks.rs @@ -0,0 +1,6 @@ +//! Benchmark targets for Zed crates. +//! +//! Benchmarks live in their own crate so benchmark-only dependencies +//! (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. diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 813a8a9bc5..1ca500832e 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -106,7 +106,6 @@ zed_actions.workspace = true zlog.workspace = true [dev-dependencies] -criterion.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } @@ -138,12 +137,3 @@ util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true - - -[[bench]] -name = "editor_render" -harness = false - -[[bench]] -name = "display_map" -harness = false diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 2270777f98..2f9c47dbef 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -26,6 +26,7 @@ test-support = [ "x11", "proptest", ] +bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] wayland = [ @@ -53,6 +54,7 @@ backtrace = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } collections.workspace = true +criterion = { workspace = true, optional = true } ctor.workspace = true derive_more.workspace = true etagere = "0.2" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 3631499fa9..794009f518 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,8 +23,8 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -#[cfg(any(test, feature = "test-support"))] -pub use bench_context::{BenchAppContext, BenchWindowContext}; +#[cfg(feature = "bench")] +pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; pub use entity_map::*; @@ -58,7 +58,7 @@ use crate::{ }; mod async_context; -#[cfg(any(test, feature = "test-support"))] +#[cfg(feature = "bench")] mod bench_context; mod context; mod entity_map; @@ -1489,7 +1489,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench"))] for window in self .windows .values() diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 6af1b7a9fa..aee68633f4 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -1,14 +1,267 @@ -use std::{future::Future, rc::Rc, sync::Arc}; +use std::{ + cell::{OnceCell, RefCell}, + future::Future, + rc::Rc, + sync::Arc, + time::Duration, +}; use anyhow::{Result, anyhow}; +use hdrhistogram::Histogram; use crate::{ - AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds, Context, Empty, - Entity, EntityId, Focusable, ForegroundExecutor, Global, Render, Reservation, Task, - TestDispatcher, TestPlatform, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::{GpuiBorrow, GpuiMode}, + AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BenchDispatcher, + Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global, + NoopTextSystem, Platform, PlatformHeadlessRenderer, 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. +/// +/// 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. +/// +/// 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`]. +/// +/// `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. +pub fn bench_platform( + headless_renderer_factory: Option Option>>>, +) -> Rc { + thread_local! { + static PLATFORM: OnceCell> = 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 + }) +} + +/// Default target frame rate when a benchmark doesn't specify `fps = N`. +const DEFAULT_FPS: u64 = 120; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +/// A small report produced by GPUI benchmarks. +#[derive(Clone)] +pub struct BenchReport { + frame_snapshot: Rc>, + frame_budget_nanos: u128, +} + +impl Default for BenchReport { + fn default() -> Self { + Self::with_fps(DEFAULT_FPS) + } +} + +impl BenchReport { + /// Creates a report whose per-frame budget is one frame at `fps` when + /// counting frame budget overruns. + pub fn with_fps(fps: u64) -> Self { + assert!(fps > 0, "frame rate must be greater than zero"); + Self::with_frame_budget_nanos(NANOS_PER_SECOND / fps as u128) + } + + /// Creates a report that treats `frame_budget_nanos` as the per-frame budget + /// when counting frame budget overruns. + pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + Self { + frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), + frame_budget_nanos, + } + } + + fn record_frame_timings<'i>(&self, timings: impl IntoIterator) { + let mut snapshot = self.frame_snapshot.borrow_mut(); + // `.ok()` on `record`: this operation is infallible (the histograms auto-resize). + for timing in timings { + snapshot + .draw + .record(timing.draw_duration().as_nanos() as u64) + .ok(); + if let Some(dirty_to_draw) = timing.dirty_to_draw_duration() { + snapshot + .dirty_to_draw + .record(dirty_to_draw.as_nanos() as u64) + .ok(); + } + if timing.invalidations > 0 { + snapshot + .invalidations_per_frame + .record(timing.invalidations) + .ok(); + } + } + } + + fn total_budget_overruns(&self, histogram: &Histogram) -> u64 { + histogram + .iter_recorded() + .map(|value| { + self.budget_overruns(Duration::from_nanos(value.value_iterated_to())) + * value.count_at_value() + }) + .sum() + } + + /// Returns how many whole frame budgets `foreground_time` exceeded the + /// per frame budget by. This is a synthetic proxy for missed frames: the + /// benchmark harness has no vsync, so it counts how many frame deadlines + /// would have elapsed while the foreground thread was busy. + fn budget_overruns(&self, foreground_time: Duration) -> u64 { + let foreground_nanos = foreground_time.as_nanos(); + if foreground_nanos <= self.frame_budget_nanos { + return 0; + } + + let over_budget_nanos = foreground_nanos - self.frame_budget_nanos; + over_budget_nanos.div_ceil(self.frame_budget_nanos) as u64 + } + + /// Prints this report to stderr. + pub fn print(&self, benchmark_name: Option<&'static str>) { + let frame_snapshot = self.frame_snapshot.borrow(); + if frame_snapshot.is_empty() { + return; + } + + let benchmark_name = benchmark_name.unwrap_or("unknown benchmark"); + eprintln!("GPUI bench report (all observed iterations): {benchmark_name}"); + eprintln!(" note: includes Criterion warmup/calibration"); + self.print_histogram("window dirty-to-draw", &frame_snapshot.dirty_to_draw); + self.print_histogram("window draw", &frame_snapshot.draw); + if !frame_snapshot.invalidations_per_frame.is_empty() { + eprintln!( + " invalidations per frame: mean {:.2}, max {}", + frame_snapshot.invalidations_per_frame.mean(), + frame_snapshot.invalidations_per_frame.max() + ); + } + } + + fn print_histogram(&self, name: &str, histogram: &Histogram) { + if histogram.is_empty() { + return; + } + + let max_foreground_time = Duration::from_nanos(histogram.max()); + eprintln!(" {name}:"); + eprintln!(" samples: {}", histogram.len()); + eprintln!( + " mean: {}", + format_duration(Duration::from_nanos(histogram.mean() as u64)) + ); + eprintln!( + " p50: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.50))) + ); + eprintln!( + " p90: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.90))) + ); + eprintln!( + " p95: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.95))) + ); + eprintln!( + " p99: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.99))) + ); + eprintln!(" max: {}", format_duration(max_foreground_time)); + eprintln!( + " frame budget overruns total: {}", + self.total_budget_overruns(histogram) + ); + eprintln!( + " frame budget overruns max: {}", + self.budget_overruns(max_foreground_time) + ); + } +} + +struct WindowFrameSnapshot { + dirty_to_draw: Histogram, + draw: Histogram, + invalidations_per_frame: Histogram, +} + +impl WindowFrameSnapshot { + fn new() -> Self { + Self { + dirty_to_draw: Histogram::new(3).expect("3 significant digits is valid"), + draw: Histogram::new(3).expect("3 significant digits is valid"), + invalidations_per_frame: Histogram::new(3).expect("3 significant digits is valid"), + } + } + + fn is_empty(&self) -> bool { + self.dirty_to_draw.is_empty() && self.draw.is_empty() + } +} + +fn format_duration(duration: Duration) -> String { + format!("{:.3}ms", duration.as_secs_f64() * 1000.) +} + +/// Enables frame tracing for the duration of a measurement and collects the +/// frames recorded within it. The previous tracing state is restored on drop, +/// so a panicking measurement doesn't leave tracing enabled for unrelated code +/// (e.g. a later benchmark in the same process). +struct FrameTraceScope { + collector: FrameTimingCollector, + was_already_enabled: bool, +} + +impl FrameTraceScope { + fn start() -> Self { + let was_already_enabled = !profiler::set_frame_trace_enabled(true); + Self { + collector: FrameTimingCollector::new(), + was_already_enabled, + } + } + + fn finish(mut self) -> Vec { + self.collector.collect_unseen() + // Dropping `self` restores the previous tracing state. + } +} + +impl Drop for FrameTraceScope { + fn drop(&mut self) { + if !self.was_already_enabled { + profiler::set_frame_trace_enabled(false); + } + } +} + /// A GPUI app context for Criterion benchmarks. /// /// `BenchAppContext` is intentionally separate from `TestAppContext`: it owns a @@ -16,41 +269,70 @@ use crate::{ /// benchmark setup. Criterion remains responsible for the measured loop via its /// `Bencher` API. #[derive(Clone)] -pub struct BenchAppContext { +pub struct BenchAppContext<'a, 'measurement> { app: Rc, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, - dispatcher: TestDispatcher, benchmark_name: Option<&'static str>, + bencher: Rc>>>, + report: BenchReport, } -impl BenchAppContext { - /// Creates a new benchmark app context. - pub fn new(benchmark_name: Option<&'static str>) -> Self { - Self::with_seed(benchmark_name, 0) +impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + pub fn new( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + ) -> Self { + Self::build(platform, benchmark_name, bencher, BenchReport::default()) } - /// Creates a new benchmark app context with the provided scheduler seed. - pub fn with_seed(benchmark_name: Option<&'static str>, seed: u64) -> Self { - Self::build(TestDispatcher::new(seed), benchmark_name) + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + #[doc(hidden)] + pub fn new_with_platform_and_report( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + Self::build(platform, benchmark_name, bencher, report) } - fn build(dispatcher: TestDispatcher, benchmark_name: Option<&'static str>) -> Self { - let dispatcher = Arc::new(dispatcher); - let background_executor = BackgroundExecutor::new(dispatcher.clone()); - let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); + fn build( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + let background_executor = platform.background_executor(); + // Validate up front so misconfiguration fails at construction with a + // clear message instead of deep inside `run_until_idle`. + assert!( + background_executor.dispatcher().as_bench().is_some(), + "BenchAppContext requires a platform whose executors are backed by a \ + BenchDispatcher; construct one with gpui::bench_platform" + ); + let foreground_executor = platform.foreground_executor(); let asset_source = Arc::new(()); let http_client = http_client::FakeHttpClient::with_404_response(); let app = App::new_app(platform, asset_source, http_client); - app.borrow_mut().mode = GpuiMode::test(); Self { app, background_executor, foreground_executor, - dispatcher: (*dispatcher).clone(), benchmark_name, + bencher: Rc::new(RefCell::new(Some(bencher))), + report, } } @@ -69,11 +351,6 @@ impl BenchAppContext { &self.foreground_executor } - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.dispatcher.run_until_parked(); - } - /// Updates the app and flushes synchronous GPUI effects afterward. pub fn update(&mut self, update: impl FnOnce(&mut App) -> R) -> R { let mut app = self.app.borrow_mut(); @@ -86,8 +363,85 @@ impl BenchAppContext { read(&app) } + /// Runs queued foreground tasks on this thread and waits for in flight + /// background work to finish. Timers that aren't due yet are not waited + /// for (see [`BenchDispatcher::run_until_idle`]). + pub fn run_until_idle(&self) { + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .run_until_idle(); + } + + /// Measures a generic benchmark workload using Criterion's iteration loop. + /// + /// The closure is invoked once per Criterion iteration with this + /// benchmark app context so it can update GPUI state. + /// + /// Any window draws triggered by the workload are recorded into the + /// benchmark's frame report through the GPUI frame profiler. + pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { + let bencher = self.take_bencher("bench_iter"); + let collector = FrameTraceScope::start(); + let mut benchmark = || benchmark(self); + bencher.iter(&mut benchmark); + self.report.record_frame_timings(collector.finish().iter()); + self.replace_bencher(bencher); + } + + /// Measures frame latency after updating a GPUI entity in its current window. + /// + /// Each iteration runs `update` against the entity in its current window. In + /// bench builds, flushing the update's effects synchronously draws dirty + /// windows. The entity should be part of the window's render tree, such as the + /// root view or a child of it. + /// + /// Frame timings are collected through the GPUI frame profiler + /// ([`crate::profiler::record_frame_timing`]), which is enabled for the + /// duration of the measurement. + pub fn bench_renderer( + &mut self, + view: Entity, + mut update: impl FnMut(&mut V, &mut Window, &mut Context), + ) where + V: 'static + Render, + { + let bencher = self.take_bencher("bench_renderer"); + let window_id = self + .with_window(view.entity_id(), |window, _| { + window.window_handle().window_id() + }) + .expect("cannot benchmark renderer for entity without a current window"); + + let collector = FrameTraceScope::start(); + + let mut benchmark = || { + self.with_window(view.entity_id(), |window, cx| { + view.update(cx, |view, cx| update(view, window, cx)); + }) + .expect("cannot benchmark renderer for entity without a current window"); + // Submit the frame drawn by the update's effect flush, mirroring + // production where every drawn frame is presented. With a headless + // renderer this includes scene submission to the GPU. + self.with_window(view.entity_id(), |window, _| { + window.present_if_needed(); + }) + .expect("cannot benchmark renderer for entity without a current window"); + }; + bencher.iter(&mut benchmark); + + let timings = collector.finish(); + self.report.record_frame_timings( + timings + .iter() + .filter(|timing| timing.window_id == window_id), + ); + self.replace_bencher(bencher); + } + /// Adds a window with an empty root view for benchmark setup. - pub fn add_empty_window(&mut self) -> BenchWindowContext { + pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { let window = { let mut app = self.app.borrow_mut(); let bounds = Bounds::maximized(None, &app); @@ -111,18 +465,40 @@ impl BenchAppContext { } } + fn take_bencher(&self, benchmark_kind: &str) -> &'a mut criterion::Bencher<'measurement> { + self.bencher.borrow_mut().take().unwrap_or_else(|| { + panic!("cannot start {benchmark_kind}: benchmark measurement is already running") + }) + } + + fn replace_bencher(&self, bencher: &'a mut criterion::Bencher<'measurement>) { + let previous = self.bencher.borrow_mut().replace(bencher); + assert!( + previous.is_none(), + "benchmark bencher was unexpectedly present after 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. pub fn teardown(mut self) { self.run_until_idle(); self.update(|cx| { - cx.background_executor().forbid_parking(); cx.quit(); }); self.run_until_idle(); + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .forget_pending_timers(); } } -impl AppContext for BenchAppContext { +impl AppContext for BenchAppContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); app.new(build_entity) @@ -151,7 +527,7 @@ impl AppContext for BenchAppContext { app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, _: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -216,14 +592,14 @@ impl AppContext for BenchAppContext { /// This is separate from `VisualTestContext`; it provides access to a benchmark /// window without exposing test-only helpers such as input simulation. #[derive(Clone)] -pub struct BenchWindowContext { - cx: BenchAppContext, +pub struct BenchWindowContext<'a, 'measurement> { + cx: BenchAppContext<'a, 'measurement>, window: AnyWindowHandle, } -impl BenchWindowContext { +impl<'a, 'measurement> BenchWindowContext<'a, 'measurement> { /// Returns the underlying benchmark app context. - pub fn app_context(&mut self) -> &mut BenchAppContext { + pub fn app_context(&mut self) -> &mut BenchAppContext<'a, 'measurement> { &mut self.cx } @@ -232,20 +608,21 @@ impl BenchWindowContext { self.window } + /// Runs queued foreground tasks on this thread and waits for in-flight + /// background work to finish. Pending timers are not waited for. + pub fn run_until_idle(&self) { + self.cx.run_until_idle(); + } + /// Updates the benchmark window. pub fn update(&mut self, update: impl FnOnce(&mut Window, &mut App) -> R) -> R { self.cx .update_window(self.window, |_, window, cx| update(window, cx)) .expect("benchmark window was unexpectedly closed") } - - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.cx.run_until_idle(); - } } -impl AppContext for BenchWindowContext { +impl AppContext for BenchWindowContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { self.window .update(&mut self.cx, |_, _, cx| cx.new(build_entity)) @@ -276,7 +653,7 @@ impl AppContext for BenchWindowContext { self.cx.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, handle: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -331,7 +708,7 @@ impl AppContext for BenchWindowContext { } } -impl VisualContext for BenchWindowContext { +impl VisualContext for BenchWindowContext<'_, '_> { type Result = Result; fn window_handle(&self) -> AnyWindowHandle { diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b792718f88..a81ff265c3 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -33,9 +33,15 @@ mod keymap; mod path_builder; mod platform; pub mod prelude; -/// Profiling utilities for task timing and thread performance tracking. +/// Profiling utilities for task, frame, and thread performance tracking. pub mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[cfg(any( + test, + target_os = "windows", + target_os = "linux", + target_family = "wasm", + feature = "bench" +))] #[expect(missing_docs)] pub mod queue; mod scene; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 06072b0d5e..60355b8e24 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +#[cfg(any(test, feature = "bench"))] +mod bench_dispatcher; + #[cfg(any(test, feature = "test-support"))] mod test; @@ -77,6 +80,9 @@ pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; +#[cfg(any(test, feature = "bench"))] +pub use bench_dispatcher::BenchDispatcher; + #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; @@ -745,6 +751,13 @@ pub trait PlatformHeadlessRenderer { size: Size, ) -> Result; + /// Render a scene to an offscreen target without reading the result back. + /// + /// This is the headless analogue of presenting a frame: it performs the + /// same CPU-side scene encoding and GPU submission as drawing to a real + /// window, but doesn't block on GPU completion or copy pixels back. + fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()>; + /// Returns the sprite atlas used by this renderer. fn sprite_atlas(&self) -> Arc; } @@ -786,6 +799,13 @@ pub trait PlatformDispatcher: Send + Sync { fn as_test(&self) -> Option<&TestDispatcher> { None } + + // This cfg must match the `bench_dispatcher` module's, which implements + // this method whenever it compiles. + #[cfg(any(test, feature = "bench"))] + fn as_bench(&self) -> Option<&BenchDispatcher> { + None + } } #[expect(missing_docs)] diff --git a/crates/gpui/src/platform/bench_dispatcher.rs b/crates/gpui/src/platform/bench_dispatcher.rs new file mode 100644 index 0000000000..80aff1c389 --- /dev/null +++ b/crates/gpui/src/platform/bench_dispatcher.rs @@ -0,0 +1,441 @@ +use std::{ + collections::BinaryHeap, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +use parking_lot::{Condvar, Mutex}; + +use crate::{ + PlatformDispatcher, Priority, RunnableVariant, profiler, + queue::{PriorityQueueReceiver, PriorityQueueSender}, +}; + +const MIN_THREADS: usize = 2; + +/// A multithreaded [`PlatformDispatcher`] for benchmarks. +/// +/// Background tasks run in parallel on a pool of worker threads and timers fire +/// in real time on a dedicated timer thread, mirroring the production +/// dispatchers (see `LinuxDispatcher`). Main-thread tasks are queued until the +/// benchmark thread drains them via [`Self::run_until_idle`], since there is no +/// platform run loop pumping them. +/// +/// Unlike [`TestDispatcher`](crate::TestDispatcher), which runs everything on a +/// single thread with a virtual clock, work dispatched through this dispatcher +/// executes with production concurrency, so wall-clock measurements reflect +/// real parallelism. +pub struct BenchDispatcher { + background_sender: PriorityQueueSender, + main_sender: PriorityQueueSender, + main_receiver: Mutex>, + timers: Arc, + idle: Arc, + main_thread_id: thread::ThreadId, +} + +/// Tracks how many background and timer runnables are queued or running so +/// [`BenchDispatcher::run_until_idle`] knows when to stop waiting. +#[derive(Default)] +struct IdleTracker { + inflight: Mutex, + condvar: Condvar, +} + +impl IdleTracker { + fn increment(&self) { + *self.inflight.lock() += 1; + } + + fn decrement(&self) { + let mut inflight = self.inflight.lock(); + *inflight -= 1; + if *inflight == 0 { + self.condvar.notify_all(); + } + } + + /// Returns a guard that decrements the in-flight count when dropped, so + /// the count stays correct even if the runnable being executed panics. + fn decrement_on_drop(&self) -> impl Drop + '_ { + gpui_util::defer(|| self.decrement()) + } + + /// Notifies waiters while holding the in-flight lock. `run_until_idle` + /// re-checks its wake conditions under this lock before waiting, so the + /// notification can't slip between its check and its wait and be lost. + fn notify_under_lock(&self) { + let _inflight = self.inflight.lock(); + self.condvar.notify_all(); + } +} + +struct TimerQueue { + state: Mutex, + condvar: Condvar, +} + +struct TimerQueueState { + heap: BinaryHeap, + next_seq: u64, +} + +struct TimerEntry { + due: Instant, + seq: u64, + runnable: RunnableVariant, +} + +impl PartialEq for TimerEntry { + fn eq(&self, other: &Self) -> bool { + self.due == other.due && self.seq == other.seq + } +} + +impl Eq for TimerEntry {} + +impl PartialOrd for TimerEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimerEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reversed so that the entry with the earliest due time (breaking ties + // by insertion order) is at the top of the max-heap. + other + .due + .cmp(&self.due) + .then_with(|| other.seq.cmp(&self.seq)) + } +} + +impl Default for BenchDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl BenchDispatcher { + /// Creates a dispatcher whose main thread is the calling thread. + /// + /// Worker and timer threads live for the lifetime of the process; the + /// dispatcher is expected to be created once and reused across benchmarks. + pub fn new() -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); + let idle = Arc::new(IdleTracker::default()); + + let thread_count = + thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + for i in 0..thread_count { + let mut receiver: PriorityQueueReceiver = background_receiver.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name(format!("BenchWorker-{i}")) + .spawn(move || { + while let Ok(runnable) = receiver.pop() { + let _decrement = idle.decrement_on_drop(); + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + } + }) + .expect("failed to spawn benchmark worker thread"); + } + drop(background_receiver); + + let timers = Arc::new(TimerQueue { + state: Mutex::new(TimerQueueState { + heap: BinaryHeap::new(), + next_seq: 0, + }), + condvar: Condvar::new(), + }); + { + let timers = timers.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name("BenchTimer".to_owned()) + .spawn(move || { + let mut state = timers.state.lock(); + loop { + let Some(entry) = state.heap.peek() else { + timers.condvar.wait(&mut state); + continue; + }; + let due = entry.due; + if due > Instant::now() { + timers.condvar.wait_until(&mut state, due); + continue; + } + let Some(entry) = state.heap.pop() else { + continue; + }; + // Count the firing timer as in-flight before releasing + // the lock so it can spawn follow-up work that + // `run_until_idle` will wait for. Lock order is always + // timer state, then in-flight count; `run_until_idle` + // never takes them in the opposite order. + idle.increment(); + drop(state); + + { + let _decrement = idle.decrement_on_drop(); + let location = entry.runnable.metadata().location; + let spawned = entry.runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + entry.runnable.run(); + profiler::save_task_timing(); + } + + state = timers.state.lock(); + } + }) + .expect("failed to spawn benchmark timer thread"); + } + + Self { + background_sender, + main_sender, + main_receiver: Mutex::new(main_receiver), + timers, + idle, + main_thread_id: thread::current().id(), + } + } + + /// Runs queued main thread tasks and waits until no background or timer + /// work is queued, running, or already due. + /// + /// Timers that haven't reached their due time yet are *not* waited for: + /// the dispatcher runs in real time and cannot skip ahead like the + /// `TestDispatcher`'s virtual clock, so waiting on a future timer would + /// block for its full real duration. Tasks sleeping on such timers are + /// considered idle. Must be called on the thread that created this + /// dispatcher. + pub fn run_until_idle(&self) { + assert!( + self.is_main_thread(), + "run_until_idle must be called on the benchmark main thread" + ); + loop { + if self.drain_main_queue() { + continue; + } + + // Checked before taking the in-flight lock; the timer thread + // locks them in the opposite order, so nesting would deadlock. + if self.has_due_timer() { + // Poll briefly: a firing timer leaves the heap just before it + // registers as in-flight. + let mut inflight = self.idle.inflight.lock(); + self.idle + .condvar + .wait_for(&mut inflight, Duration::from_millis(1)); + continue; + } + + let mut inflight = self.idle.inflight.lock(); + // Re-checked under the lock that `dispatch_on_main_thread` + // notifies under, so the notification can't be lost. + if self.main_queue_has_work() { + continue; + } + if *inflight == 0 { + // Main-thread sends happen before in-flight decrements, and + // decrements happen under this lock, so the check above + // observed all completed work. + return; + } + // Woken when main-thread work arrives or the in-flight count + // reaches zero; both notify under this lock. + self.idle.condvar.wait(&mut inflight); + } + } + + /// Forgets 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); + } + } + + fn has_due_timer(&self) -> bool { + let state = self.timers.state.lock(); + state + .heap + .peek() + .is_some_and(|entry| entry.due <= Instant::now()) + } + + fn main_queue_has_work(&self) -> bool { + !self.main_receiver.lock().is_empty() + } + + fn drain_main_queue(&self) -> bool { + let mut ran_any = false; + loop { + // Lock only around the pop so runnables can re-entrantly dispatch + // more main-thread work through the sender while they run. + let runnable = self.main_receiver.lock().try_pop(); + match runnable { + Ok(Some(runnable)) => { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + ran_any = true; + } + Ok(None) | Err(_) => return ran_any, + } + } + } +} + +impl PlatformDispatcher for BenchDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + self.idle.increment(); + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("benchmark worker threads are no longer running")); + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(error) = self.main_sender.send(priority, runnable) { + // The main receiver lives as long as this dispatcher, so a failed + // send means we're mid-teardown. The runnable may wrap a !Send + // future, so forget it rather than dropping it on this thread + // (mirrors LinuxDispatcher). + std::mem::forget(error); + return; + } + // Wake `run_until_idle` if it's waiting for main-thread work. + self.idle.notify_under_lock(); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let mut state = self.timers.state.lock(); + let seq = state.next_seq; + state.next_seq += 1; + state.heap.push(TimerEntry { + due: Instant::now() + duration, + seq, + runnable, + }); + self.timers.condvar.notify_one(); + } + + fn spawn_realtime(&self, f: Box) { + // Benchmarks don't need realtime scheduling priority; a plain thread + // keeps this portable. + thread::Builder::new() + .name("BenchRealtime".to_owned()) + .spawn(f) + .expect("failed to spawn benchmark realtime thread"); + } + + fn as_bench(&self) -> Option<&BenchDispatcher> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + use crate::{BackgroundExecutor, ForegroundExecutor}; + + #[test] + fn run_until_idle_completes_background_to_main_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(); + + dispatcher.run_until_idle(); + assert!(completed.load(Ordering::SeqCst)); + } + + #[test] + fn timers_fire_in_real_time() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(10)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !fired.load(Ordering::SeqCst) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert!(fired.load(Ordering::SeqCst)); + } + + #[test] + fn forget_pending_timers_prevents_stale_timers_from_firing() { + 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)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + dispatcher.forget_pending_timers(); + + thread::sleep(Duration::from_millis(400)); + dispatcher.run_until_idle(); + assert!(!fired.load(Ordering::SeqCst)); + } +} diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 2b5399ca98..8894350edc 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -6,6 +6,7 @@ use crate::{ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use gpui_util::ResultExt as _; use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; @@ -291,7 +292,14 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &Scene) {} + fn draw(&self, scene: &Scene) { + let scale_factor = self.scale_factor(); + let mut state = self.0.lock(); + let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); + if let Some(renderer) = &mut state.renderer { + renderer.render_scene(scene, device_size).warn_on_err(); + } + } fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() @@ -299,10 +307,10 @@ impl PlatformWindow for TestWindow { #[cfg(any(test, feature = "test-support"))] fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let scale_factor = self.scale_factor(); let mut state = self.0.lock(); let size = state.bounds.size; if let Some(renderer) = &mut state.renderer { - let scale_factor = 2.0; let device_size: Size = size.to_device_pixels(scale_factor); renderer.render_scene_to_image(scene, device_size) } else { diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index d4318853c3..c9e9e8c587 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -18,7 +18,7 @@ pub(crate) use actions::{save_action_timing, update_running_action}; use serde::{Deserialize, Serialize}; -use crate::{SharedString, TasksIncluded}; +use crate::{SharedString, TasksIncluded, WindowId}; #[cfg(feature = "profiler")] #[doc(hidden)] @@ -695,3 +695,128 @@ pub fn set_trace_enabled(enabled: bool) -> bool { pub fn trace_enabled() -> bool { PROFILER_ENABLED.load(Ordering::Relaxed) } + +/// Timing for a single drawn window frame. +#[derive(Debug, Copy, Clone)] +pub struct FrameTiming { + /// The window that was drawn. + pub window_id: WindowId, + /// When the frame first became dirty (its first invalidation). `None` if + /// frame tracing was not yet enabled when the invalidation occurred. + pub dirty_at: Option, + /// Number of invalidations coalesced into this frame. + pub invalidations: u64, + /// When `Window::draw` started. + pub draw_start: Instant, + /// When `Window::draw` finished. + pub draw_end: Instant, +} + +impl FrameTiming { + /// Time spent inside `Window::draw`. + pub fn draw_duration(&self) -> Duration { + self.draw_end.duration_since(self.draw_start) + } + + /// Time from the frame's first invalidation to the end of its draw, if the + /// first invalidation was observed. + pub fn dirty_to_draw_duration(&self) -> Option { + self.dirty_at + .map(|dirty_at| self.draw_end.duration_since(dirty_at)) + } +} + +// Allow 16MiB of frame timing entries. +const MAX_FRAME_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); + +struct FrameTimings { + timings: VecDeque, + total_pushed: u64, +} + +static FRAME_TIMINGS: spin::Mutex = spin::Mutex::new(FrameTimings { + timings: VecDeque::new(), + total_pushed: 0, +}); + +static FRAME_TRACE_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables or disables frame timing collection at runtime. +/// +/// When transitioning from enabled to disabled, the buffered frame timings are +/// cleared so stale data isn't reported after a later re-enable. Returns false +/// if the value was unchanged. +pub fn set_frame_trace_enabled(enabled: bool) -> bool { + if FRAME_TRACE_ENABLED.swap(enabled, Ordering::AcqRel) == enabled { + return false; + } + + if !enabled { + let mut frames = FRAME_TIMINGS.lock(); + frames.timings.clear(); + frames.timings.shrink_to_fit(); + frames.total_pushed = 0; + } + true +} + +/// Returns whether frame timing collection is enabled. +pub fn frame_trace_enabled() -> bool { + FRAME_TRACE_ENABLED.load(Ordering::Relaxed) +} + +/// Records the timing of a drawn window frame. +/// +/// No-op unless frame tracing is enabled via [`set_frame_trace_enabled`]. +pub fn record_frame_timing(timing: FrameTiming) { + if !frame_trace_enabled() { + return; + } + std::hint::cold_path(); // optimize for when profiling is off + + let mut frames = FRAME_TIMINGS.lock(); + if frames.timings.len() >= MAX_FRAME_TIMINGS { + frames.timings.pop_front(); + } + frames.timings.push_back(timing); + frames.total_pushed += 1; +} + +/// Drains frame timings recorded after this collector was created, tracking a +/// cursor so each call to [`Self::collect_unseen`] returns only new entries. +pub struct FrameTimingCollector { + cursor: u64, +} + +impl Default for FrameTimingCollector { + fn default() -> Self { + Self::new() + } +} + +impl FrameTimingCollector { + /// Creates a collector that only sees frames recorded from this point on. + pub fn new() -> Self { + Self { + cursor: FRAME_TIMINGS.lock().total_pushed, + } + } + + /// Returns frame timings recorded since the previous call (or since the + /// collector was created). If the ring buffer wrapped around since the + /// previous poll, the evicted entries are lost. + pub fn collect_unseen(&mut self) -> Vec { + let frames = FRAME_TIMINGS.lock(); + let buffer_len = frames.timings.len() as u64; + let buffer_start = frames.total_pushed.saturating_sub(buffer_len); + let skip = self.cursor.saturating_sub(buffer_start) as usize; + let unseen = frames + .timings + .iter() + .skip(skip.min(frames.timings.len())) + .copied() + .collect(); + self.cursor = frames.total_pushed; + unseen + } +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 6e7cf2445e..f289048815 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -220,6 +220,11 @@ impl PriorityQueueReceiver { (sender, receiver) } + /// Returns whether the queue currently contains no elements. + pub fn is_empty(&self) -> bool { + self.state.queues.lock().is_empty() + } + /// Tries to pop one element from the priority queue without blocking. /// /// This will early return if there are no elements in the queue. diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f52fde9aeb..d5fce7a0aa 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -117,6 +117,17 @@ struct WindowInvalidatorInner { pub draw_phase: DrawPhase, pub dirty_views: FxHashSet, pub update_count: usize, + pub frame_dirty: FrameDirtyAccumulator, +} + +/// Per-frame invalidation bookkeeping, drained at draw time and emitted to the +/// frame profiler. Tracks when the current frame first became dirty and how +/// many invalidations were coalesced into it. Only populated while +/// `profiler::frame_trace_enabled()` is set. +#[derive(Default)] +struct FrameDirtyAccumulator { + dirty_at: Option, + invalidations: u64, } #[derive(Clone)] @@ -132,6 +143,7 @@ impl WindowInvalidator { draw_phase: DrawPhase::None, dirty_views: FxHashSet::default(), update_count: 0, + frame_dirty: FrameDirtyAccumulator::default(), })), } } @@ -141,6 +153,7 @@ impl WindowInvalidator { inner.update_count += 1; inner.dirty_views.insert(entity); if inner.draw_phase == DrawPhase::None { + Self::record_frame_dirty(&mut inner); inner.dirty = true; cx.push_effect(Effect::Notify { emitter: entity }); true @@ -158,6 +171,7 @@ impl WindowInvalidator { inner.dirty = dirty; if dirty { inner.update_count += 1; + Self::record_frame_dirty(&mut inner); } } @@ -169,6 +183,17 @@ impl WindowInvalidator { self.inner.borrow().update_count } + fn record_frame_dirty(inner: &mut WindowInvalidatorInner) { + if profiler::frame_trace_enabled() { + inner.frame_dirty.dirty_at.get_or_insert_with(Instant::now); + inner.frame_dirty.invalidations += 1; + } + } + + fn take_frame_dirty(&self) -> FrameDirtyAccumulator { + mem::take(&mut self.inner.borrow_mut().frame_dirty) + } + pub fn take_views(&self) -> FxHashSet { mem::take(&mut self.inner.borrow_mut().dirty_views) } @@ -2576,6 +2601,11 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + // Drain unconditionally so a stale first-invalidation timestamp can't + // leak into a later frame across enable/disable of frame tracing. + let frame_dirty = self.invalidator.take_frame_dirty(); + let draw_started_at = profiler::frame_trace_enabled().then(Instant::now); + // Set up the per-App arena for element allocation during this draw. // This ensures that multiple test Apps have isolated arenas. let _arena_scope = ElementArenaScope::enter(&cx.element_arena); @@ -2669,6 +2699,16 @@ impl Window { self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); + if let Some(draw_start) = draw_started_at { + profiler::record_frame_timing(profiler::FrameTiming { + window_id: self.handle.window_id(), + dirty_at: frame_dirty.dirty_at, + invalidations: frame_dirty.invalidations, + draw_start, + draw_end: Instant::now(), + }); + } + ArenaClearNeeded::new(&cx.element_arena) } @@ -2703,6 +2743,18 @@ impl Window { profiling::finish_frame!(); } + /// Presents the most recently drawn frame if it hasn't been presented yet. + /// + /// Benchmarks drive drawing synchronously rather than through a platform + /// frame-request loop, so they call this after each measured update to + /// submit the frame like production presentation would. + #[cfg(feature = "bench")] + pub fn present_if_needed(&mut self) { + if self.needs_present.get() { + self.present(); + } + } + /// Returns a snapshot of the current input-latency histograms. #[cfg(feature = "input-latency-histogram")] pub fn input_latency_snapshot(&self) -> InputLatencySnapshot { diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 73b53ce6ea..5a72e23e14 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -133,6 +133,10 @@ pub(crate) struct MetalRenderer { path_intermediate_texture: Option, path_intermediate_msaa_texture: Option, path_sample_count: u32, + /// Offscreen render target reused across `render_scene` calls when + /// rendering headlessly without reading pixels back. + #[cfg(any(test, feature = "test-support"))] + headless_render_target: Option, } #[repr(C)] @@ -347,6 +351,8 @@ impl MetalRenderer { path_intermediate_texture: None, path_intermediate_msaa_texture: None, path_sample_count: PATH_SAMPLE_COUNT, + #[cfg(any(test, feature = "test-support"))] + headless_render_target: None, } } @@ -729,6 +735,86 @@ impl MetalRenderer { } } + /// Renders a scene to a reused offscreen texture without reading pixels + /// back or blocking on GPU completion. + /// + /// This mirrors the CPU cost of presenting a frame to a window (scene + /// encoding, instance buffer writes, command submission) and is used by + /// headless benchmark rendering, where the produced pixels are never + /// inspected. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()> { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene: {:?}", size); + } + + self.update_path_intermediate_textures(size); + + let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_target { + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .headless_render_target + .clone() + .expect("just ensured the render target exists"); + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // Commit without waiting, mirroring presentation to a real + // window where the CPU doesn't block on the GPU. + command_buffer.commit(); + return Ok(()); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + fn draw_primitives( &mut self, scene: &Scene, @@ -1703,6 +1789,10 @@ impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { self.renderer.render_scene_to_image(scene, size) } + fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.renderer.render_scene(scene, size) + } + fn sprite_atlas(&self) -> Arc { self.renderer.sprite_atlas().clone() } diff --git a/crates/gpui_macros/src/bench.rs b/crates/gpui_macros/src/bench.rs index 7d7b2ad893..d5fad465e4 100644 --- a/crates/gpui_macros/src/bench.rs +++ b/crates/gpui_macros/src/bench.rs @@ -1,15 +1,35 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{ItemFn, spanned::Spanned}; +use syn::{ItemFn, parse::Parser, spanned::Spanned}; pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { + let mut fps: Option = None; if !args.is_empty() { - return error_to_stream(syn::Error::new( - proc_macro2::TokenStream::from(args).span(), - "#[gpui::bench] does not accept arguments yet", - )); + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("fps") { + let value: syn::LitInt = meta.value()?.parse()?; + let value = value.base10_parse::()?; + if value == 0 { + return Err(meta.error("#[gpui::bench] `fps` must be greater than zero")); + } + fps = Some(value); + Ok(()) + } else { + Err(meta.error("#[gpui::bench] only accepts `fps = N`")) + } + }); + if let Err(error) = parser.parse(args) { + return error_to_stream(error); + } } + // The frame budget math lives in `BenchReport` so `bench_context` is the + // single source of truth; `default()` supplies the default frame rate. + let report_expr = match fps { + Some(fps) => quote! { gpui::BenchReport::with_fps(#fps) }, + None => quote! { gpui::BenchReport::default() }, + }; + let mut inner_fn = match syn::parse::(function) { Ok(function) => function, Err(error) => return error_to_stream(error), @@ -30,11 +50,23 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { #inner_fn fn #outer_fn_name(criterion: &mut criterion::Criterion) { - criterion.bench_function(stringify!(#outer_fn_name), |bencher| { - let mut cx = gpui::BenchAppContext::new(Some(stringify!(#outer_fn_name))); - #inner_fn_name(bencher, &mut cx); - cx.teardown(); + 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() + }))), + Some(stringify!(#outer_fn_name)), + bencher, + report.clone(), + ); + #inner_fn_name(&mut cx); + cx.teardown(); + } }); + report.print(Some(stringify!(#outer_fn_name))); } }) diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 1fe3fa97ea..41ad72c67a 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -191,6 +191,10 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { } /// `#[gpui::bench]` annotates a Criterion benchmark that runs with GPUI support. +/// +/// 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. #[proc_macro_attribute] pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { bench::bench(args, function)