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
215 lines
7.0 KiB
Rust
215 lines
7.0 KiB
Rust
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
|
use editor::{MultiBuffer, display_map::*};
|
|
use gpui::{AppContext as _, HighlightStyle, Hsla, TestDispatcher, font, px};
|
|
use itertools::Itertools;
|
|
use multi_buffer::MultiBufferOffset;
|
|
use project::project_settings::DiagnosticSeverity;
|
|
use rand::{Rng, SeedableRng, rngs::StdRng};
|
|
use settings::SettingsStore;
|
|
use std::{num::NonZeroU32, time::Duration};
|
|
use text::Bias;
|
|
use util::RandomCharIter;
|
|
|
|
fn to_tab_point_benchmark(c: &mut Criterion) {
|
|
let dispatcher = TestDispatcher::new(1);
|
|
let cx = gpui::TestAppContext::build(dispatcher, None);
|
|
|
|
let create_tab_map = |length: usize| {
|
|
let mut rng = StdRng::seed_from_u64(1);
|
|
let text = RandomCharIter::new(&mut rng)
|
|
.take(length)
|
|
.collect::<String>();
|
|
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
|
|
|
|
let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx));
|
|
use editor::display_map::*;
|
|
let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
|
|
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone());
|
|
let fold_point = fold_snapshot.to_fold_point(
|
|
inlay_snapshot.to_point(InlayOffset(
|
|
rng.random_range(MultiBufferOffset(0)..MultiBufferOffset(length)),
|
|
)),
|
|
Bias::Left,
|
|
);
|
|
let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap());
|
|
|
|
(length, snapshot, fold_point)
|
|
};
|
|
|
|
let inputs = [1024].into_iter().map(create_tab_map).collect_vec();
|
|
|
|
let mut group = c.benchmark_group("To tab point");
|
|
|
|
for (batch_size, snapshot, fold_point) in inputs {
|
|
group.bench_with_input(
|
|
BenchmarkId::new("to_tab_point", batch_size),
|
|
&snapshot,
|
|
|bench, snapshot| {
|
|
bench.iter(|| {
|
|
snapshot.fold_point_to_tab_point(fold_point);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
group.finish();
|
|
}
|
|
|
|
fn to_fold_point_benchmark(c: &mut Criterion) {
|
|
let dispatcher = TestDispatcher::new(1);
|
|
let cx = gpui::TestAppContext::build(dispatcher, None);
|
|
|
|
let create_tab_map = |length: usize| {
|
|
let mut rng = StdRng::seed_from_u64(1);
|
|
let text = RandomCharIter::new(&mut rng)
|
|
.take(length)
|
|
.collect::<String>();
|
|
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
|
|
|
|
let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx));
|
|
use editor::display_map::*;
|
|
let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot);
|
|
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone());
|
|
|
|
let fold_point = fold_snapshot.to_fold_point(
|
|
inlay_snapshot.to_point(InlayOffset(
|
|
rng.random_range(MultiBufferOffset(0)..MultiBufferOffset(length)),
|
|
)),
|
|
Bias::Left,
|
|
);
|
|
|
|
let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap());
|
|
let tab_point = snapshot.fold_point_to_tab_point(fold_point);
|
|
|
|
(length, snapshot, tab_point)
|
|
};
|
|
|
|
let inputs = [1024].into_iter().map(create_tab_map).collect_vec();
|
|
|
|
let mut group = c.benchmark_group("To fold point");
|
|
|
|
for (batch_size, snapshot, tab_point) in inputs {
|
|
group.bench_with_input(
|
|
BenchmarkId::new("to_fold_point", batch_size),
|
|
&snapshot,
|
|
|bench, snapshot| {
|
|
bench.iter(|| {
|
|
snapshot.tab_point_to_fold_point(tab_point, Bias::Left);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
group.finish();
|
|
}
|
|
|
|
fn create_highlight_endpoints_benchmark(c: &mut Criterion) {
|
|
const LINE_COUNT: usize = 20_000;
|
|
const LINE_VIEW_PORT_COUNT: usize = 100;
|
|
const HIGHLIGHTS_PER_LINE: usize = 4;
|
|
|
|
let dispatcher = TestDispatcher::new(1);
|
|
let mut cx = gpui::TestAppContext::build(dispatcher, None);
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
editor::init(cx);
|
|
});
|
|
|
|
let mut text = String::new();
|
|
let mut highlight_ranges = Vec::with_capacity(LINE_COUNT * HIGHLIGHTS_PER_LINE);
|
|
for line in 0..LINE_COUNT {
|
|
text.push_str("fn item_");
|
|
text.push_str(&format!("{line:05}"));
|
|
text.push_str("() { ");
|
|
|
|
let start = text.len();
|
|
text.push_str("alpha_highlight");
|
|
highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len()));
|
|
|
|
text.push_str(" + ");
|
|
let start = text.len();
|
|
text.push_str("beta_highlight");
|
|
highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len()));
|
|
|
|
text.push_str(" + ");
|
|
let start = text.len();
|
|
text.push_str("gamma_highlight");
|
|
highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len()));
|
|
|
|
text.push_str(" + ");
|
|
let start = text.len();
|
|
text.push_str("delta_highlight");
|
|
highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len()));
|
|
|
|
text.push_str("; }\n");
|
|
}
|
|
|
|
let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
|
|
let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx));
|
|
let highlight_ranges = highlight_ranges
|
|
.into_iter()
|
|
.map(|range| {
|
|
buffer_snapshot.anchor_before(range.start)..buffer_snapshot.anchor_before(range.end)
|
|
})
|
|
.collect();
|
|
|
|
let map = cx.new(|cx| {
|
|
DisplayMap::new(
|
|
buffer,
|
|
font("Courier"),
|
|
px(16.0),
|
|
None,
|
|
1,
|
|
1,
|
|
FoldPlaceholder::default(),
|
|
DiagnosticSeverity::Warning,
|
|
cx,
|
|
)
|
|
});
|
|
cx.update(|cx| {
|
|
map.update(cx, |map, cx| {
|
|
map.highlight_text(
|
|
HighlightKey::Editor,
|
|
highlight_ranges,
|
|
HighlightStyle {
|
|
color: Some(Hsla::blue()),
|
|
..Default::default()
|
|
},
|
|
false,
|
|
cx,
|
|
);
|
|
});
|
|
});
|
|
let snapshot = cx.update(|cx| map.update(cx, |map, cx| map.snapshot(cx)));
|
|
|
|
let mut group = c.benchmark_group("Create highlight endpoints");
|
|
group.sample_size(10);
|
|
group.measurement_time(Duration::from_secs(10));
|
|
group.bench_with_input(
|
|
BenchmarkId::new("text_highlights", LINE_VIEW_PORT_COUNT),
|
|
&snapshot,
|
|
|bench, snapshot| {
|
|
bench.iter(|| {
|
|
black_box(snapshot.chunks(
|
|
DisplayRow(400)..DisplayRow(400 + LINE_VIEW_PORT_COUNT as u32),
|
|
language::LanguageAwareStyling {
|
|
tree_sitter: false,
|
|
diagnostics: false,
|
|
},
|
|
Default::default(),
|
|
));
|
|
});
|
|
},
|
|
);
|
|
group.finish();
|
|
}
|
|
|
|
criterion_group!(
|
|
benches,
|
|
to_tab_point_benchmark,
|
|
to_fold_point_benchmark,
|
|
create_highlight_endpoints_benchmark
|
|
);
|
|
criterion_main!(benches);
|