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
430 lines
12 KiB
Rust
430 lines
12 KiB
Rust
use std::{
|
|
collections::VecDeque,
|
|
fmt,
|
|
iter::FusedIterator,
|
|
sync::{Arc, atomic::AtomicUsize},
|
|
};
|
|
|
|
use rand::{Rng, SeedableRng, rngs::SmallRng};
|
|
|
|
use crate::Priority;
|
|
|
|
struct PriorityQueues<T> {
|
|
high_priority: VecDeque<T>,
|
|
medium_priority: VecDeque<T>,
|
|
low_priority: VecDeque<T>,
|
|
}
|
|
|
|
impl<T> PriorityQueues<T> {
|
|
fn is_empty(&self) -> bool {
|
|
self.high_priority.is_empty()
|
|
&& self.medium_priority.is_empty()
|
|
&& self.low_priority.is_empty()
|
|
}
|
|
}
|
|
|
|
struct PriorityQueueState<T> {
|
|
queues: parking_lot::Mutex<PriorityQueues<T>>,
|
|
condvar: parking_lot::Condvar,
|
|
receiver_count: AtomicUsize,
|
|
sender_count: AtomicUsize,
|
|
}
|
|
|
|
impl<T> PriorityQueueState<T> {
|
|
fn send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
|
|
if self
|
|
.receiver_count
|
|
.load(std::sync::atomic::Ordering::Relaxed)
|
|
== 0
|
|
{
|
|
return Err(SendError(item));
|
|
}
|
|
|
|
let mut queues = self.queues.lock();
|
|
Self::push(&mut queues, priority, item);
|
|
self.condvar.notify_one();
|
|
Ok(())
|
|
}
|
|
|
|
fn spin_send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
|
|
if self
|
|
.receiver_count
|
|
.load(std::sync::atomic::Ordering::Relaxed)
|
|
== 0
|
|
{
|
|
return Err(SendError(item));
|
|
}
|
|
|
|
let mut queues = loop {
|
|
if let Some(guard) = self.queues.try_lock() {
|
|
break guard;
|
|
}
|
|
std::hint::spin_loop();
|
|
};
|
|
Self::push(&mut queues, priority, item);
|
|
self.condvar.notify_one();
|
|
Ok(())
|
|
}
|
|
|
|
fn push(queues: &mut PriorityQueues<T>, priority: Priority, item: T) {
|
|
match priority {
|
|
Priority::RealtimeAudio => unreachable!(
|
|
"Realtime audio priority runs on a dedicated thread and is never queued"
|
|
),
|
|
Priority::High => queues.high_priority.push_back(item),
|
|
Priority::Medium => queues.medium_priority.push_back(item),
|
|
Priority::Low => queues.low_priority.push_back(item),
|
|
};
|
|
}
|
|
|
|
fn recv<'a>(&'a self) -> Result<parking_lot::MutexGuard<'a, PriorityQueues<T>>, RecvError> {
|
|
let mut queues = self.queues.lock();
|
|
|
|
let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed);
|
|
if queues.is_empty() && sender_count == 0 {
|
|
return Err(crate::queue::RecvError);
|
|
}
|
|
|
|
while queues.is_empty() {
|
|
self.condvar.wait(&mut queues);
|
|
}
|
|
|
|
Ok(queues)
|
|
}
|
|
|
|
fn try_recv<'a>(
|
|
&'a self,
|
|
) -> Result<Option<parking_lot::MutexGuard<'a, PriorityQueues<T>>>, RecvError> {
|
|
let mut queues = self.queues.lock();
|
|
|
|
let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed);
|
|
if queues.is_empty() && sender_count == 0 {
|
|
return Err(crate::queue::RecvError);
|
|
}
|
|
|
|
if queues.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(queues))
|
|
}
|
|
}
|
|
|
|
fn spin_try_recv<'a>(
|
|
&'a self,
|
|
) -> Result<Option<parking_lot::MutexGuard<'a, PriorityQueues<T>>>, RecvError> {
|
|
let queues = loop {
|
|
if let Some(guard) = self.queues.try_lock() {
|
|
break guard;
|
|
}
|
|
std::hint::spin_loop();
|
|
};
|
|
|
|
let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed);
|
|
if queues.is_empty() && sender_count == 0 {
|
|
return Err(crate::queue::RecvError);
|
|
}
|
|
|
|
if queues.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(queues))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub struct PriorityQueueSender<T> {
|
|
state: Arc<PriorityQueueState<T>>,
|
|
}
|
|
|
|
impl<T> PriorityQueueSender<T> {
|
|
fn new(state: Arc<PriorityQueueState<T>>) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
pub fn send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
|
|
self.state.send(priority, item)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn spin_send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
|
|
self.state.spin_send(priority, item)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<T> Drop for PriorityQueueSender<T> {
|
|
fn drop(&mut self) {
|
|
self.state
|
|
.sender_count
|
|
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub struct PriorityQueueReceiver<T> {
|
|
state: Arc<PriorityQueueState<T>>,
|
|
rand: SmallRng,
|
|
disconnected: bool,
|
|
}
|
|
|
|
impl<T> Clone for PriorityQueueReceiver<T> {
|
|
fn clone(&self) -> Self {
|
|
self.state
|
|
.receiver_count
|
|
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
|
Self {
|
|
state: Arc::clone(&self.state),
|
|
rand: SmallRng::seed_from_u64(0),
|
|
disconnected: self.disconnected,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub struct SendError<T>(pub T);
|
|
|
|
impl<T: fmt::Debug> fmt::Debug for SendError<T> {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_tuple("SendError").field(&self.0).finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
#[doc(hidden)]
|
|
pub struct RecvError;
|
|
|
|
#[allow(dead_code)]
|
|
impl<T> PriorityQueueReceiver<T> {
|
|
pub fn new() -> (PriorityQueueSender<T>, Self) {
|
|
let state = PriorityQueueState {
|
|
queues: parking_lot::Mutex::new(PriorityQueues {
|
|
high_priority: VecDeque::new(),
|
|
medium_priority: VecDeque::new(),
|
|
low_priority: VecDeque::new(),
|
|
}),
|
|
condvar: parking_lot::Condvar::new(),
|
|
receiver_count: AtomicUsize::new(1),
|
|
sender_count: AtomicUsize::new(1),
|
|
};
|
|
let state = Arc::new(state);
|
|
|
|
let sender = PriorityQueueSender::new(Arc::clone(&state));
|
|
|
|
let receiver = PriorityQueueReceiver {
|
|
state,
|
|
rand: SmallRng::seed_from_u64(0),
|
|
disconnected: false,
|
|
};
|
|
|
|
(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.
|
|
///
|
|
/// This method is best suited if you only intend to pop one element, for better performance
|
|
/// on large queues see [`Self::try_iter`]
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If the sender was dropped
|
|
pub fn try_pop(&mut self) -> Result<Option<T>, RecvError> {
|
|
self.pop_inner(false)
|
|
}
|
|
|
|
pub fn spin_try_pop(&mut self) -> Result<Option<T>, RecvError> {
|
|
use Priority as P;
|
|
|
|
let Some(mut queues) = self.state.spin_try_recv()? else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let high = P::High.weight() * !queues.high_priority.is_empty() as u32;
|
|
let medium = P::Medium.weight() * !queues.medium_priority.is_empty() as u32;
|
|
let low = P::Low.weight() * !queues.low_priority.is_empty() as u32;
|
|
let mut mass = high + medium + low;
|
|
|
|
if !queues.high_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::High.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.high_priority.pop_front());
|
|
}
|
|
mass -= P::High.weight();
|
|
}
|
|
|
|
if !queues.medium_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::Medium.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.medium_priority.pop_front());
|
|
}
|
|
mass -= P::Medium.weight();
|
|
}
|
|
|
|
if !queues.low_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::Low.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.low_priority.pop_front());
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Pops an element from the priority queue blocking if necessary.
|
|
///
|
|
/// This method is best suited if you only intend to pop one element, for better performance
|
|
/// on large queues see [`Self::iter``]
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If the sender was dropped
|
|
pub fn pop(&mut self) -> Result<T, RecvError> {
|
|
self.pop_inner(true).map(|e| e.unwrap())
|
|
}
|
|
|
|
/// Returns an iterator over the elements of the queue
|
|
/// this iterator will end when all elements have been consumed and will not wait for new ones.
|
|
pub fn try_iter(self) -> TryIter<T> {
|
|
TryIter {
|
|
receiver: self,
|
|
ended: false,
|
|
}
|
|
}
|
|
|
|
/// Returns an iterator over the elements of the queue
|
|
/// this iterator will wait for new elements if the queue is empty.
|
|
pub fn iter(self) -> Iter<T> {
|
|
Iter(self)
|
|
}
|
|
|
|
#[inline(always)]
|
|
// algorithm is the loaded die from biased coin from
|
|
// https://www.keithschwarz.com/darts-dice-coins/
|
|
fn pop_inner(&mut self, block: bool) -> Result<Option<T>, RecvError> {
|
|
use Priority as P;
|
|
|
|
let mut queues = if !block {
|
|
let Some(queues) = self.state.try_recv()? else {
|
|
return Ok(None);
|
|
};
|
|
queues
|
|
} else {
|
|
self.state.recv()?
|
|
};
|
|
|
|
let high = P::High.weight() * !queues.high_priority.is_empty() as u32;
|
|
let medium = P::Medium.weight() * !queues.medium_priority.is_empty() as u32;
|
|
let low = P::Low.weight() * !queues.low_priority.is_empty() as u32;
|
|
let mut mass = high + medium + low; //%
|
|
|
|
if !queues.high_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::High.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.high_priority.pop_front());
|
|
}
|
|
mass -= P::High.weight();
|
|
}
|
|
|
|
if !queues.medium_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::Medium.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.medium_priority.pop_front());
|
|
}
|
|
mass -= P::Medium.weight();
|
|
}
|
|
|
|
if !queues.low_priority.is_empty() {
|
|
let flip = self.rand.random_ratio(P::Low.weight(), mass);
|
|
if flip {
|
|
return Ok(queues.low_priority.pop_front());
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
impl<T> Drop for PriorityQueueReceiver<T> {
|
|
fn drop(&mut self) {
|
|
self.state
|
|
.receiver_count
|
|
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub struct Iter<T>(PriorityQueueReceiver<T>);
|
|
impl<T> Iterator for Iter<T> {
|
|
type Item = T;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
self.0.pop().ok()
|
|
}
|
|
}
|
|
impl<T> FusedIterator for Iter<T> {}
|
|
|
|
#[doc(hidden)]
|
|
pub struct TryIter<T> {
|
|
receiver: PriorityQueueReceiver<T>,
|
|
ended: bool,
|
|
}
|
|
impl<T> Iterator for TryIter<T> {
|
|
type Item = Result<T, RecvError>;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
if self.ended {
|
|
return None;
|
|
}
|
|
|
|
let res = self.receiver.try_pop();
|
|
self.ended = res.is_err();
|
|
|
|
res.transpose()
|
|
}
|
|
}
|
|
impl<T> FusedIterator for TryIter<T> {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use collections::HashSet;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn all_tasks_get_yielded() {
|
|
let (tx, mut rx) = PriorityQueueReceiver::new();
|
|
tx.send(Priority::Medium, 20).unwrap();
|
|
tx.send(Priority::High, 30).unwrap();
|
|
tx.send(Priority::Low, 10).unwrap();
|
|
tx.send(Priority::Medium, 21).unwrap();
|
|
tx.send(Priority::High, 31).unwrap();
|
|
|
|
drop(tx);
|
|
|
|
assert_eq!(
|
|
rx.iter().collect::<HashSet<_>>(),
|
|
[30, 31, 20, 21, 10].into_iter().collect::<HashSet<_>>()
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn new_high_prio_task_get_scheduled_quickly() {
|
|
let (tx, mut rx) = PriorityQueueReceiver::new();
|
|
for _ in 0..100 {
|
|
tx.send(Priority::Low, 1).unwrap();
|
|
}
|
|
|
|
assert_eq!(rx.pop().unwrap(), 1);
|
|
tx.send(Priority::High, 3).unwrap();
|
|
assert_eq!(rx.pop().unwrap(), 3);
|
|
assert_eq!(rx.pop().unwrap(), 1);
|
|
}
|
|
}
|