input_latency_ui.rs

  1use collections::HashMap;
  2use gpui::{App, Global, InputLatencySnapshot, Window, WindowId, actions};
  3use hdrhistogram::Histogram;
  4use std::time::Instant;
  5
  6actions!(
  7    dev,
  8    [
  9        /// Opens a buffer showing the input-to-frame latency histogram for the current window.
 10        DumpInputLatencyHistogram,
 11    ]
 12);
 13
 14/// Generates a formatted text report of the input-to-frame latency histogram
 15/// for the given window. If a previous report was generated (tracked via a
 16/// global on the `App`), includes a delta section showing changes since that
 17/// report.
 18pub fn format_input_latency_report(window: &Window, cx: &mut App) -> String {
 19    let snapshot = window.input_latency_snapshot();
 20    let state = cx.default_global::<ReporterState>();
 21    let report = format_report(&snapshot, state);
 22
 23    state.previous_snapshot = Some(snapshot);
 24    state.previous_timestamp = Some(chrono::Local::now());
 25
 26    report
 27}
 28
 29#[derive(Default)]
 30struct ReporterState {
 31    previous_snapshot: Option<InputLatencySnapshot>,
 32    previous_timestamp: Option<chrono::DateTime<chrono::Local>>,
 33}
 34
 35impl Global for ReporterState {}
 36
 37/// Per-window state used for telemetry delta computation. Kept separate from
 38/// `ReporterState` so the user-facing dump and the background telemetry flush
 39/// maintain independent baselines.
 40#[derive(Default)]
 41struct TelemetryReporterState {
 42    /// Keyed by window id. Each entry holds the cumulative snapshot at the time
 43    /// of the last telemetry flush, plus the wall-clock time of that flush.
 44    previous: HashMap<WindowId, (Instant, InputLatencySnapshot)>,
 45}
 46
 47impl Global for TelemetryReporterState {}
 48
 49/// Nanosecond boundaries for the time-range buckets used in telemetry.
 50/// These match the display distribution in format_report so the two stay in sync.
 51const MS4_NS: u64 = 4_000_000;
 52const MS8_NS: u64 = 8_000_000;
 53const MS16_NS: u64 = 16_000_000;
 54const MS33_NS: u64 = 33_000_000;
 55const MS100_NS: u64 = 100_000_000;
 56
 57/// Minimum number of frames that must be present in the delta window for the
 58/// telemetry report to be sent. Avoids sending noise for windows that are
 59/// mostly idle.
 60const MIN_FRAMES_TO_REPORT: u64 = 5_000;
 61
 62/// Computes and sends a `input_latency_report` telemetry event for the given
 63/// window if enough frames have been recorded since the last report.
 64///
 65/// Call this periodically (e.g. every five minutes) from a spawned task. A
 66/// separate baseline snapshot is kept per window so user-facing histogram dumps
 67/// and telemetry never share state.
 68pub fn report_input_latency_telemetry(window: &Window, cx: &mut App) {
 69    let current = window.input_latency_snapshot();
 70    let window_id = window.window_handle().window_id();
 71
 72    let state = cx.default_global::<TelemetryReporterState>();
 73    let now = Instant::now();
 74
 75    let (delta_latency, delta_coalesce, report_window_seconds) =
 76        if let Some((prev_instant, prev_snapshot)) = state.previous.get(&window_id) {
 77            let mut delta_latency = current.latency_histogram.clone();
 78            delta_latency
 79                .subtract(&prev_snapshot.latency_histogram)
 80                .ok();
 81            let mut delta_coalesce = current.events_per_frame_histogram.clone();
 82            delta_coalesce
 83                .subtract(&prev_snapshot.events_per_frame_histogram)
 84                .ok();
 85            let elapsed = now.duration_since(*prev_instant).as_secs();
 86            (delta_latency, delta_coalesce, elapsed)
 87        } else {
 88            // First report for this window: the full cumulative histogram is the
 89            // delta from the empty starting state. We don't know how long the
 90            // window has been open, so record 0 to signal that this is the
 91            // initial accumulation period rather than a fixed-width window.
 92            (
 93                current.latency_histogram.clone(),
 94                current.events_per_frame_histogram.clone(),
 95                0u64,
 96            )
 97        };
 98
 99    let total_frames = delta_latency.len();
100    if total_frames < MIN_FRAMES_TO_REPORT {
101        return;
102    }
103
104    state.previous.insert(window_id, (now, current));
105
106    let frames_sub4 = count_frames_in_range(&delta_latency, 0, MS4_NS);
107    let frames_4to8 = count_frames_in_range(&delta_latency, MS4_NS, MS8_NS);
108    let frames_8to16 = count_frames_in_range(&delta_latency, MS8_NS, MS16_NS);
109    let frames_16to33 = count_frames_in_range(&delta_latency, MS16_NS, MS33_NS);
110    let frames_33to100 = count_frames_in_range(&delta_latency, MS33_NS, MS100_NS);
111    // frames > 100 ms are implicitly total_frames - (sub4 + 4to8 + 8to16 + 16to33 + 33to100)
112
113    let frames_with_1_event = count_frames_in_range(&delta_coalesce, 1, 2);
114    let frames_with_2_events = count_frames_in_range(&delta_coalesce, 2, 3);
115    let frames_with_3_events = count_frames_in_range(&delta_coalesce, 3, 4);
116    // frames with 4+ events are implicitly total_frames - (1 + 2 + 3)
117
118    telemetry::event!(
119        "Latency Report",
120        frames_sub4 = frames_sub4,
121        frames_4to8 = frames_4to8,
122        frames_8to16 = frames_8to16,
123        frames_16to33 = frames_16to33,
124        frames_33to100 = frames_33to100,
125        total_frames = total_frames,
126        frames_with_1_event = frames_with_1_event,
127        frames_with_2_events = frames_with_2_events,
128        frames_with_3_events = frames_with_3_events,
129        report_window_seconds = report_window_seconds,
130    );
131}
132
133fn count_frames_in_range(histogram: &Histogram<u64>, low_ns: u64, high_ns: u64) -> u64 {
134    histogram
135        .iter_recorded()
136        .filter(|v| v.value_iterated_to() >= low_ns && v.value_iterated_to() < high_ns)
137        .map(|v| v.count_at_value())
138        .sum()
139}
140
141fn format_report(snapshot: &InputLatencySnapshot, previous: &ReporterState) -> String {
142    let histogram = &snapshot.latency_histogram;
143    let total = histogram.len();
144
145    if total == 0 {
146        return "No input latency samples recorded yet.\n\nTry typing or clicking in a buffer first.".to_string();
147    }
148
149    let percentiles: &[(&str, f64)] = &[
150        ("min  ", 0.0),
151        ("p50  ", 0.50),
152        ("p75  ", 0.75),
153        ("p90  ", 0.90),
154        ("p95  ", 0.95),
155        ("p99  ", 0.99),
156        ("p99.9", 0.999),
157        ("max  ", 1.0),
158    ];
159
160    let now = chrono::Local::now();
161
162    let mut report = String::new();
163    report.push_str("Input Latency Histogram\n");
164    report.push_str("=======================\n");
165
166    let timestamp = now.format("%Y-%m-%d %H:%M:%S %Z");
167    report.push_str(&format!("Timestamp: {timestamp}\n"));
168    report.push_str(&format!("Samples: {total}\n"));
169    if snapshot.mid_draw_events_dropped > 0 {
170        report.push_str(&format!(
171            "Mid-draw events excluded: {}\n",
172            snapshot.mid_draw_events_dropped
173        ));
174    }
175
176    write_latency_percentiles(&mut report, "Percentiles", histogram, percentiles);
177    write_latency_distribution(&mut report, "Distribution", histogram);
178
179    let coalesce = &snapshot.events_per_frame_histogram;
180    let coalesce_total = coalesce.len();
181    if coalesce_total > 0 {
182        report.push('\n');
183        report.push_str("Events coalesced per frame:\n");
184        for (label, quantile) in percentiles {
185            let value = if *quantile == 0.0 {
186                coalesce.min()
187            } else if *quantile == 1.0 {
188                coalesce.max()
189            } else {
190                coalesce.value_at_quantile(*quantile)
191            };
192            report.push_str(&format!("  {label}: {value:>6} events\n"));
193        }
194
195        report.push('\n');
196        report.push_str("Distribution:\n");
197        let bar_width = 30usize;
198        let max_count = coalesce.max();
199        for n in 1..=max_count {
200            let count = coalesce
201                .iter_recorded()
202                .filter(|value| value.value_iterated_to() == n)
203                .map(|value| value.count_at_value())
204                .sum::<u64>();
205            if count == 0 {
206                continue;
207            }
208            let fraction = count as f64 / coalesce_total as f64;
209            let bar_len = (fraction * bar_width as f64) as usize;
210            let bar = "\u{2588}".repeat(bar_len);
211            report.push_str(&format!(
212                "  {n:>6} events: {count:>6} ({:>5.1}%) {bar}\n",
213                fraction * 100.0,
214            ));
215        }
216    }
217
218    // Delta section: compare against the previous report's snapshot.
219    if let (Some(prev_snapshot), Some(prev_timestamp)) =
220        (&previous.previous_snapshot, &previous.previous_timestamp)
221    {
222        let prev_latency = &prev_snapshot.latency_histogram;
223        let prev_total = prev_latency.len();
224        let delta_total = total - prev_total;
225
226        report.push('\n');
227        report.push_str("Delta Since Last Report\n");
228        report.push_str("-----------------------\n");
229        let prev_ts = prev_timestamp.format("%Y-%m-%d %H:%M:%S %Z");
230        let elapsed_secs = (now - *prev_timestamp).num_seconds().max(0);
231        report.push_str(&format!(
232            "Previous report: {prev_ts} ({elapsed_secs}s ago)\n"
233        ));
234        report.push_str(&format!("New samples: {delta_total}\n"));
235
236        if delta_total > 0 {
237            let mut delta_histogram = histogram.clone();
238            delta_histogram.subtract(prev_latency).ok();
239
240            write_latency_percentiles(
241                &mut report,
242                "Percentiles (new samples only)",
243                &delta_histogram,
244                percentiles,
245            );
246            write_latency_distribution(
247                &mut report,
248                "Distribution (new samples only)",
249                &delta_histogram,
250            );
251        }
252    }
253
254    report
255}
256
257fn write_latency_percentiles(
258    report: &mut String,
259    heading: &str,
260    histogram: &Histogram<u64>,
261    percentiles: &[(&str, f64)],
262) {
263    let ns_to_ms = |ns: u64| ns as f64 / 1_000_000.0;
264
265    report.push('\n');
266    report.push_str(heading);
267    report.push_str(":\n");
268    for (label, quantile) in percentiles {
269        let value_ns = if *quantile == 0.0 {
270            histogram.min()
271        } else if *quantile == 1.0 {
272            histogram.max()
273        } else {
274            histogram.value_at_quantile(*quantile)
275        };
276        let hz = if value_ns > 0 {
277            1_000_000_000.0 / value_ns as f64
278        } else {
279            f64::INFINITY
280        };
281        report.push_str(&format!(
282            "  {label}: {:>8.2}ms  ({:>7.1} Hz)\n",
283            ns_to_ms(value_ns),
284            hz
285        ));
286    }
287}
288
289fn write_latency_distribution(report: &mut String, heading: &str, histogram: &Histogram<u64>) {
290    const BUCKETS: &[(u64, u64, &str, &str)] = &[
291        (0, 4_000_000, "0\u{2013}4ms", "(excellent)"),
292        (4_000_000, 8_000_000, "4\u{2013}8ms", "(120fps)"),
293        (8_000_000, 16_000_000, "8\u{2013}16ms", "(60fps)"),
294        (16_000_000, 33_000_000, "16\u{2013}33ms", "(30fps)"),
295        (33_000_000, 100_000_000, "33\u{2013}100ms", ""),
296        (100_000_000, u64::MAX, "100ms+", "(sluggish)"),
297    ];
298    let bar_width = 30usize;
299    let total = histogram.len() as f64;
300
301    report.push('\n');
302    report.push_str(heading);
303    report.push_str(":\n");
304    for (low, high, range, note) in BUCKETS {
305        let count: u64 = histogram
306            .iter_recorded()
307            .filter(|value| value.value_iterated_to() >= *low && value.value_iterated_to() < *high)
308            .map(|value| value.count_at_value())
309            .sum();
310        let fraction = if total > 0.0 {
311            count as f64 / total
312        } else {
313            0.0
314        };
315        let bar_len = (fraction * bar_width as f64) as usize;
316        let bar = "\u{2588}".repeat(bar_len);
317        report.push_str(&format!(
318            "  {range:>8}  {note:<11}: {count:>6} ({:>5.1}%) {bar}\n",
319            fraction * 100.0,
320        ));
321    }
322}