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:
1 parent
5c6fbb2674
commit
6d9ddb96b2
23 files changed
+1343
-107
No files matched your search
Generated
+32
-2
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@ members = [
|
||||
"crates/auto_update_ui",
|
||||
"crates/aws_http_client",
|
||||
"crates/bedrock",
|
||||
"crates/benchmarks",
|
||||
"crates/breadcrumbs",
|
||||
"crates/buffer_diff",
|
||||
"crates/call",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
File renamed without changes.
File renamed without changes.
+29
-29
@@ -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);
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user