Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion crates/SphereUIComponents/src/components/mixer_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2764,10 +2764,35 @@ pub struct VstiOutputMeterState {
pub level: f32,
pub peak_hold: f32,
pub clip: bool,
/// Meter tick this entry was last published in. Bookkeeping for the
/// meter path's prune pass: an entry still carrying the current
/// generation is live, an older one is decaying toward removal. Kept
/// here so liveness needs no parallel set of owned key strings — see
/// `apply_engine_meters`.
pub last_seen: u64,
}

pub fn vsti_output_meter_key(track_id: &str, insert_id: &str, channel: u8) -> String {
format!("{track_id}:{insert_id}:{channel}")
let mut key = String::new();
write_vsti_output_meter_key(&mut key, track_id, insert_id, channel);
key
}

/// Format a meter key into a reusable buffer.
///
/// The meter path resolves one key per plugin output channel on every tick, so
/// on a project with thousands of VSTi output channels the allocating form
/// above is hundreds of microseconds of pure `String` churn per tick. Callers
/// on that path keep one buffer and rewrite it in place.
pub fn write_vsti_output_meter_key(
buffer: &mut String,
track_id: &str,
insert_id: &str,
channel: u8,
) {
use std::fmt::Write;
buffer.clear();
let _ = write!(buffer, "{track_id}:{insert_id}:{channel}");
}

#[derive(Clone, Copy)]
Expand Down Expand Up @@ -3650,6 +3675,66 @@ mod mixer_virtualization_tests {
}
}

#[cfg(test)]
mod vsti_meter_key_tests {
use super::{vsti_output_meter_key, write_vsti_output_meter_key, VstiOutputMeterState};

/// The buffered form is what the meter path uses every tick; it has to
/// produce byte-identical keys to the allocating form the mixer render
/// path still calls, or lookups would silently miss.
#[test]
fn buffered_and_allocating_keys_are_identical() {
let cases = [
("track-1", "insert-7", 1u8),
("vsti-out:abc:bus:3", "slot-0", 32),
("", "", 0),
];
let mut buffer = String::new();
for (track_id, insert_id, channel) in cases {
write_vsti_output_meter_key(&mut buffer, track_id, insert_id, channel);
assert_eq!(
buffer,
vsti_output_meter_key(track_id, insert_id, channel),
"key mismatch for {track_id}/{insert_id}/{channel}"
);
}
}

/// Reusing one buffer must never leak the previous key's tail.
#[test]
fn reused_buffer_never_keeps_a_longer_previous_key() {
let mut buffer = String::new();
write_vsti_output_meter_key(&mut buffer, "a-very-long-track-id", "insert-12", 31);
write_vsti_output_meter_key(&mut buffer, "t", "i", 1);
assert_eq!(buffer, "t:i:1");
}

/// Liveness is now a generation stamp rather than a set of live keys.
/// A meter published this tick is live; one that stopped publishing must
/// fall through to the decay branch exactly as before.
#[test]
fn generation_stamp_separates_live_from_stale_entries() {
let generation = 41u64;
let live = VstiOutputMeterState {
level: 0.5,
peak_hold: 0.5,
clip: false,
last_seen: generation,
};
let stale = VstiOutputMeterState {
level: 0.5,
peak_hold: 0.5,
clip: false,
last_seen: generation - 1,
};
assert!(live.last_seen == generation, "published this tick");
assert!(stale.last_seen != generation, "not published this tick");
// A fresh entry starts stale, so it only survives once the meter loop
// stamps it — matching `or_default()` + live-set insertion before.
assert_eq!(VstiOutputMeterState::default().last_seen, 0);
}
}

#[cfg(test)]
mod mixer_scrollbar_tests {
use super::{MixerScrollbarGeometry, MIXER_SCROLLBAR_MIN_THUMB, STRIP_WIDTH};
Expand Down
268 changes: 268 additions & 0 deletions crates/SphereUIComponents/src/components/performance_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ pub struct PerformanceOverlaySnapshot {
pub has_sample: bool,
pub repaint_reason: String,
pub audio: String,
/// Most expensive instrumented scopes this window, worst first. Answers
/// "where is the frame going" directly on screen, instead of requiring an
/// env var and a log file.
pub top_scopes: Vec<crate::perf::ScopeSample>,
/// Instrumented CPU per frame from the last completed perf window.
pub ui_cpu_ms: f32,
/// File timestamp of the running executable, so the panel proves which
/// build produced the numbers next to it.
pub build_stamp: String,
}

pub fn performance_overlay(snapshot: &PerformanceOverlaySnapshot) -> impl IntoElement {
Expand Down Expand Up @@ -55,7 +64,266 @@ pub fn performance_overlay(snapshot: &PerformanceOverlaySnapshot) -> impl IntoEl
overlay_line("Frame", &frame),
overlay_line("Peak", &peak),
overlay_line("Repaint", &snapshot.repaint_reason),
overlay_line("Build", &snapshot.build_stamp),
])
.children(frame_accounting_rows(snapshot.ui_cpu_ms, snapshot.frame_ms))
.children(hot_scope_rows(&snapshot.top_scopes))
}

/// Split the frame into "code we measured" and "everything else".
///
/// Without this the scope list is easy to misread: four scopes at 0.1 ms next
/// to a 40 ms frame looks like the profiler is broken, when it is actually the
/// finding — the thread is stalled outside instrumented code.
///
/// `frame_ms` comes from the overlay's own frame diagnostics, never from the
/// perf collector: this block must appear even when the collector has thin
/// data, because that is exactly the case it explains.
fn frame_accounting_rows(cpu_ms: f32, frame_ms: f32) -> Vec<gpui::AnyElement> {
let profile = gpui::frame_profile::frame_profile();
let pct = |ms: f32| {
if frame_ms > 0.0 {
100.0 * ms / frame_ms
} else {
0.0
}
};
let draw_ms = profile.draw_ms();
let present_ms = profile.present_ms();
let unaccounted = (frame_ms - cpu_ms - draw_ms - present_ms).max(0.0);

vec![
section_label("Frame breakdown"),
overlay_scope_row(
"UI CPU",
&format!("{cpu_ms:.2} ms {:.0}%", pct(cpu_ms)),
pct(cpu_ms),
)
.into_any_element(),
// `draw` covers building, laying out, and painting the element tree;
// `present` is handing the finished scene to the GPU.
overlay_scope_row(
"GPUI draw",
&format!("{draw_ms:.2} ms {:.0}%", pct(draw_ms)),
pct(draw_ms),
)
.into_any_element(),
overlay_scope_row(
"GPUI present",
&format!("{present_ms:.2} ms {:.0}%", pct(present_ms)),
pct(present_ms),
)
.into_any_element(),
overlay_scope_row(
"Unaccounted",
&format!("{unaccounted:.2} ms {:.0}%", pct(unaccounted)),
pct(unaccounted),
)
.into_any_element(),
// The draw split. `prepaint` builds the element tree and lays it out
// (the app's own render functions run inside it); `paint` walks the
// laid-out tree emitting primitives; `a11y` rebuilds the accessibility
// tree when a client has switched it on.
section_label("Draw split"),
overlay_scope_row(
"prepaint+layout",
&format!(
"{:.2} ms {:.0}%",
profile.prepaint_ms(),
pct(profile.prepaint_ms())
),
pct(profile.prepaint_ms()),
)
.into_any_element(),
overlay_scope_row(
"paint",
&format!(
"{:.2} ms {:.0}%",
profile.paint_ms(),
pct(profile.paint_ms())
),
pct(profile.paint_ms()),
)
.into_any_element(),
overlay_scope_row(
"a11y tree",
&format!(
"{:.2} ms {:.0}%",
profile.a11y_ms(),
pct(profile.a11y_ms())
),
pct(profile.a11y_ms()),
)
.into_any_element(),
overlay_scope_row(
"Text shape",
&format!(
"{:.2} ms {:.0}% x{}",
profile.shape_ms(),
pct(profile.shape_ms()),
profile.shape_misses
),
pct(profile.shape_ms()),
)
.into_any_element(),
// Inside prepaint: how much is the layout solve itself, and how much of
// that is measure callbacks. Taffy runs several sizing passes, so a
// measure count far above the node count is the thing to fix.
overlay_scope_row(
"layout solve",
&format!(
"{:.2} ms {:.0}%",
profile.layout_solve_ms(),
pct(profile.layout_solve_ms())
),
pct(profile.layout_solve_ms()),
)
.into_any_element(),
overlay_scope_row(
"measure",
&format!(
"{:.2} ms {:.0}% x{}",
profile.measure_ms(),
pct(profile.measure_ms()),
profile.measure_calls
),
pct(profile.measure_ms()),
)
.into_any_element(),
// Layout nodes, not primitives, is what prepaint cost scales with.
overlay_line(
"Nodes / prims",
&format!("{} / {}", profile.layout_nodes, profile.scene_primitives),
)
.into_any_element(),
]
}

fn section_label(text: &'static str) -> gpui::AnyElement {
div()
.pt(px(6.0))
.mt(px(4.0))
.border_t(px(1.0))
.border_color(Colors::border_subtle())
.text_size(px(10.0))
.text_color(Colors::text_muted())
.child(text)
.into_any_element()
}

/// "Where the frame went" rows. Hidden entirely until the collector has
/// something, so the panel keeps its compact shape on an idle window.
fn hot_scope_rows(scopes: &[crate::perf::ScopeSample]) -> Vec<gpui::AnyElement> {
if scopes.is_empty() {
return Vec::new();
}
let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(scopes.len() + 1);
rows.push(
div()
.pt(px(6.0))
.mt(px(4.0))
.border_t(px(1.0))
.border_color(Colors::border_subtle())
.text_size(px(10.0))
.text_color(Colors::text_muted())
.child("Hot scopes (ms/s)")
.into_any_element(),
);
for scope in scopes {
rows.push(
overlay_scope_row(
scope.name,
&format!(
"{:.1} ms {:.0}% x{}",
scope.total_ms, scope.percent, scope.count
),
scope.percent,
)
.into_any_element(),
);
}
rows
}

#[cfg(test)]
mod tests {
use super::{frame_accounting_rows, hot_scope_rows};
use crate::perf::ScopeSample;

/// The breakdown is the block that explains a profiler which otherwise
/// looks broken, so it must render unconditionally — including before any
/// perf window has completed (cpu 0.0) and on a degenerate frame time.
#[test]
fn breakdown_always_renders_every_row() {
// Frame breakdown: label + UI CPU + draw + present + unaccounted.
// Draw split: label + prepaint + paint + a11y + text + solve + measure
// + node counts.
const EXPECTED_ROWS: usize = 13;
assert_eq!(frame_accounting_rows(0.2, 40.0).len(), EXPECTED_ROWS);
assert_eq!(frame_accounting_rows(0.0, 0.0).len(), EXPECTED_ROWS);
// A frame cheaper than the measured CPU (clock jitter) must not
// produce a negative remainder or panic.
assert_eq!(frame_accounting_rows(5.0, 1.0).len(), EXPECTED_ROWS);
}

#[test]
fn no_scope_rows_when_the_collector_has_nothing() {
assert!(hot_scope_rows(&[]).is_empty(), "panel stays compact");
}

/// One header row plus one row per scope, so the panel height is
/// predictable and the rows cannot silently disappear.
#[test]
fn scope_rows_are_header_plus_one_per_scope() {
let scopes = vec![
ScopeSample {
name: "poll_native_audio",
total_ms: 480.0,
percent: 62.0,
count: 140,
},
ScopeSample {
name: "Timeline",
total_ms: 40.0,
percent: 5.0,
count: 60,
},
];
assert_eq!(hot_scope_rows(&scopes).len(), scopes.len() + 1);
}
}

fn overlay_scope_row(label: &str, value: &str, percent: f32) -> impl IntoElement {
// A scope taking most of the window is the answer, so colour it like one.
let value_color = if percent >= 50.0 {
Colors::status_error()
} else if percent >= 25.0 {
Colors::accent_warning()
} else {
Colors::text_secondary()
};
div()
.flex()
.flex_row()
.items_start()
.justify_between()
.gap(px(8.0))
.child(
div()
.w(px(112.0))
.text_size(px(10.0))
.text_color(Colors::text_muted())
.truncate()
.child(label.to_string()),
)
.child(
div()
.flex_1()
.min_w(px(0.0))
.text_size(px(10.0))
.text_color(value_color)
.child(value.to_string()),
)
}

fn overlay_title(text: &'static str) -> impl IntoElement {
Expand Down
Loading
Loading