Bench app context phase 2 (#58202)

This PR builds out GPUI's benchmark harness so render benchmarks measure
realistic frame costs using GPUI-owned, runtime-gated instrumentation.
It adds measurements for frame draw time, dirty-to-draw latency,
invalidation coalescing, and frame-budget overruns, and runs benchmark
workloads with production-like concurrency.

## How it works

**Frame timings flow through the GPUI profiler.** `Window::draw` emits a
`FrameTiming { window_id, dirty_at, invalidations, draw_start, draw_end
}` event into a global ring buffer in `gpui::profiler`, mirroring the
existing task-timing channel. Collection is runtime-gated by
`profiler::set_frame_trace_enabled` (one relaxed atomic load when
disabled — no `Instant::now` calls in production). `BenchReport` is a
pure listener: it drains events through a cursor-based
`FrameTimingCollector` and builds histograms in the bench layer.
`Window` carries no bench-only cfg fields, and the same event channel
can later feed the miniprofiler UI or an in-app frame-time HUD.

**`BenchDispatcher`** is a multithreaded `PlatformDispatcher` for
benchmarks: background tasks run on a worker pool (same priority queue
as `LinuxDispatcher`, with task-profiler hooks), timers fire in real
time on a dedicated thread, and foreground tasks queue until the bench
thread drains them with a blocking `run_until_idle()`. Unlike
`TestDispatcher`, work executes in parallel in real time, so wall-clock
measurements reflect production concurrency. In-flight accounting is
panic-safe via drop guards.

**`gpui::bench_platform()`** returns a per-process `TestPlatform` backed
by the `BenchDispatcher`, cached in a thread-local so worker threads
persist across Criterion calibration passes. This replaces the earlier
approach of constructing a real platform per invocation, which had
process-global singleton issues, never ran foreground tasks (no run loop
pumped the main queue), and couldn't open windows on headless CI.

**Text shaping** uses `NoopTextSystem`: deterministic across
machines/font installations and CI-portable. Measured cost of this
trade: ~10% of editor draw time vs `MacTextSystem` (Noop still emits one
glyph per character at fixed advances, so downstream layout/paint
structure is preserved).

**GPU coverage (macOS only for now).** `PlatformHeadlessRenderer` gained
`render_scene`, which encodes and submits the scene to Metal against a
cached offscreen target without blocking on completion or reading pixels
back — matching production `present()` CPU cost (`render_scene_to_image`
would overstate it: it waits for the GPU and copies pixels back).
`TestWindow::draw` forwards scenes to the renderer, the real
`MetalAtlas` means glyph/SVG rasterization happens during paint, and
`bench_renderer` presents after each measured update. Platforms without
a headless renderer degrade to discarding the scene.

## Example

```rust
#[gpui::bench]
fn editor_render(cx: &mut BenchAppContext) {
    init_context(cx);

    let buffer = cx.update(|cx| { /* build a MultiBuffer */ });

    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;
    });
}
```

## Example output (release, M-series)

```
editor_render           time:   [329.75 µs 330.17 µs 330.69 µs]

GPUI bench report (all observed iterations): editor_render
  note: includes Criterion warmup/calibration
  window dirty-to-draw:
    samples: 31533
    mean: 0.321ms
    p50: 0.322ms
    p90: 0.336ms
    p95: 0.342ms
    p99: 0.360ms
    max: 0.504ms
    frame budget overruns total: 0
    frame budget overruns max: 0
  window draw:
    samples: 31533
    mean: 0.295ms
    p50: 0.295ms
    p90: 0.307ms
    p95: 0.313ms
    p99: 0.330ms
    max: 0.455ms
    frame budget overruns total: 0
    frame budget overruns max: 0
  invalidations per frame: mean 5.00, max 5
```

(`invalidations per frame: mean 5.00` is real signal: each `move_down`
notifies the window five times before the draw.)

## Known limitations

- **Draw-per-flush**: the harness draws synchronously when effects flush
rather than coalescing invalidations to a vsync tick, so `dirty-to-draw`
excludes queueing delay, and `frame budget overruns` is a draw-time
budget proxy rather than actual missed presents. A frame-paced mode is
natural follow-up work.
- **GPU submission is measured on macOS only**; other platforms have no
headless renderer yet.
- The GPUI report includes Criterion warmup/calibration samples (noted
in the output); Criterion's `time` is the regression-gating number.
- `run_until_idle` waits for queued, running, and already-due work, but
not for timers that haven't reached their due time — the dispatcher runs
in real time and can't skip ahead like `TestDispatcher`'s virtual clock.

## Future work

- A vsync-like frame-pacing mode (suppress draw-on-flush; tick-driven
draw + present) so dirty-to-draw captures queueing delay
- Record present duration in `FrameTiming` so the report can split draw
vs present
- Benches that scroll through novel content (cold layout caches) and an
agent-panel render bench
- Headless renderers for Windows/Linux
- Move benches into a dedicated crate

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 is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Anthony Eid authored and GitHub committed 2026-06-10 09:26:50 +00:00
1 parent 5c6fbb2674
commit 6d9ddb96b2
23 files changed
+1343 -107

No files matched your search

Generated
+32 -2
View File
@@ -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",
+1
View File
@@ -20,6 +20,7 @@ members = [
"crates/auto_update_ui",
"crates/aws_http_client",
"crates/bedrock",
"crates/benchmarks",
"crates/breadcrumbs",
"crates/buffer_diff",
"crates/call",
-6
View File
@@ -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"]
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
@@ -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);
+6
View File
@@ -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.
-10
View File
@@ -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
+2
View File
@@ -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"
+4 -4
View File
@@ -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()
+419 -42
View File
@@ -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<Box<dyn Fn() -> Option<Box<dyn PlatformHeadlessRenderer>>>>,
) -> Rc<dyn Platform> {
thread_local! {
static PLATFORM: OnceCell<Rc<TestPlatform>> = 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>
})
}
/// 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<RefCell<WindowFrameSnapshot>>,
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<Item = &'i FrameTiming>) {
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>) -> 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<u64>) {
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<u64>,
draw: Histogram<u64>,
invalidations_per_frame: Histogram<u64>,
}
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<FrameTiming> {
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<AppCell>,
background_executor: BackgroundExecutor,
foreground_executor: ForegroundExecutor,
dispatcher: TestDispatcher,
benchmark_name: Option<&'static str>,
bencher: Rc<RefCell<Option<&'a mut criterion::Bencher<'measurement>>>>,
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<dyn Platform>,
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<dyn Platform>,
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<dyn Platform>,
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<R>(&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<V>(
&mut self,
view: Entity<V>,
mut update: impl FnMut(&mut V, &mut Window, &mut Context<V>),
) 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<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
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<T>) -> GpuiBorrow<'a, T>
fn as_mut<'b, T>(&'b mut self, _: &Entity<T>) -> 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<R>(&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<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
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<T>) -> GpuiBorrow<'a, T>
fn as_mut<'b, T>(&'b mut self, handle: &Entity<T>) -> GpuiBorrow<'b, T>
where
T: 'static,
{
@@ -331,7 +708,7 @@ impl AppContext for BenchWindowContext {
}
}
impl VisualContext for BenchWindowContext {
impl VisualContext for BenchWindowContext<'_, '_> {
type Result<T> = Result<T>;
fn window_handle(&self) -> AnyWindowHandle {
+8 -2
View File
@@ -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;
+20
View File
@@ -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<DevicePixels>,
) -> Result<RgbaImage>;
/// 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<DevicePixels>) -> Result<()>;
/// Returns the sprite atlas used by this renderer.
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
}
@@ -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)]
@@ -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<RunnableVariant>,
main_sender: PriorityQueueSender<RunnableVariant>,
main_receiver: Mutex<PriorityQueueReceiver<RunnableVariant>>,
timers: Arc<TimerQueue>,
idle: Arc<IdleTracker>,
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<usize>,
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<TimerQueueState>,
condvar: Condvar,
}
struct TimerQueueState {
heap: BinaryHeap<TimerEntry>,
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<std::cmp::Ordering> {
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<RunnableVariant> = 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<dyn FnOnce() + Send>) {
// 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));
}
}
+10 -2
View File
@@ -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<dyn FnMut()>) {}
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<DevicePixels> = 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<dyn crate::PlatformAtlas> {
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<RgbaImage> {
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<DevicePixels> = size.to_device_pixels(scale_factor);
renderer.render_scene_to_image(scene, device_size)
} else {
+126 -1
View File
@@ -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<Instant>,
/// 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<Duration> {
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::<FrameTiming>();
struct FrameTimings {
timings: VecDeque<FrameTiming>,
total_pushed: u64,
}
static FRAME_TIMINGS: spin::Mutex<FrameTimings> = 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<FrameTiming> {
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
}
}
+5
View File
@@ -220,6 +220,11 @@ impl<T> PriorityQueueReceiver<T> {
(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.
+52
View File
@@ -117,6 +117,17 @@ struct WindowInvalidatorInner {
pub draw_phase: DrawPhase,
pub dirty_views: FxHashSet<EntityId>,
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<Instant>,
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<EntityId> {
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 {
+90
View File
@@ -133,6 +133,10 @@ pub(crate) struct MetalRenderer {
path_intermediate_texture: Option<metal::Texture>,
path_intermediate_msaa_texture: Option<metal::Texture>,
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<metal::Texture>,
}
#[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<DevicePixels>) -> 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<DevicePixels>) -> anyhow::Result<()> {
self.renderer.render_scene(scene, size)
}
fn sprite_atlas(&self) -> Arc<dyn gpui::PlatformAtlas> {
self.renderer.sprite_atlas().clone()
}
+41 -9
View File
@@ -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<u64> = 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::<u64>()?;
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::<ItemFn>(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)));
}
})
+4
View File
@@ -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)