miniprofiler_ui.rs

  1use std::{
  2    hash::{DefaultHasher, Hash, Hasher},
  3    path::PathBuf,
  4    rc::Rc,
  5    time::{Duration, Instant},
  6};
  7
  8use gpui::{
  9    App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement,
 10    ParentElement as _, ProfilingCollector, Render, SerializedLocation, SerializedTaskTiming,
 11    SerializedThreadTaskTimings, SharedString, StatefulInteractiveElement, Styled, Task,
 12    ThreadTimingsDelta, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds,
 13    WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list,
 14};
 15use rpc::{AnyProtoClient, proto};
 16use util::ResultExt;
 17use workspace::{
 18    Workspace,
 19    ui::{
 20        ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, ContextMenu, Divider,
 21        DropdownMenu, ScrollAxes, ScrollableHandle as _, Scrollbars, ToggleState, Tooltip,
 22        WithScrollbar, h_flex, v_flex,
 23    },
 24};
 25use zed_actions::OpenPerformanceProfiler;
 26
 27const NANOS_PER_MS: u128 = 1_000_000;
 28const VISIBLE_WINDOW_NANOS: u128 = 10 * 1_000_000_000;
 29const REMOTE_POLL_INTERVAL: Duration = Duration::from_millis(500);
 30
 31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 32enum ProfileSource {
 33    Foreground,
 34    AllThreads,
 35    RemoteForeground,
 36    RemoteAllThreads,
 37}
 38
 39impl ProfileSource {
 40    fn label(&self) -> &'static str {
 41        match self {
 42            ProfileSource::Foreground => "Foreground",
 43            ProfileSource::AllThreads => "All threads",
 44            ProfileSource::RemoteForeground => "Remote: Foreground",
 45            ProfileSource::RemoteAllThreads => "Remote: All threads",
 46        }
 47    }
 48
 49    fn is_remote(&self) -> bool {
 50        matches!(
 51            self,
 52            ProfileSource::RemoteForeground | ProfileSource::RemoteAllThreads
 53        )
 54    }
 55
 56    fn foreground_only(&self) -> bool {
 57        matches!(
 58            self,
 59            ProfileSource::Foreground | ProfileSource::RemoteForeground
 60        )
 61    }
 62}
 63
 64pub fn init(startup_time: Instant, cx: &mut App) {
 65    cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| {
 66        let workspace_handle = cx.entity().downgrade();
 67        workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| {
 68            open_performance_profiler(startup_time, workspace_handle.clone(), window, cx);
 69        });
 70    })
 71    .detach();
 72}
 73
 74fn open_performance_profiler(
 75    startup_time: Instant,
 76    workspace_handle: WeakEntity<Workspace>,
 77    _window: &mut gpui::Window,
 78    cx: &mut App,
 79) {
 80    let existing_window = cx
 81        .windows()
 82        .into_iter()
 83        .find_map(|window| window.downcast::<ProfilerWindow>());
 84
 85    if let Some(existing_window) = existing_window {
 86        existing_window
 87            .update(cx, |profiler_window, window, _cx| {
 88                profiler_window.workspace = Some(workspace_handle.clone());
 89                window.activate_window();
 90            })
 91            .log_err();
 92        return;
 93    }
 94
 95    let window_background = cx.theme().window_background_appearance();
 96    let default_bounds = size(px(1280.), px(720.));
 97
 98    cx.defer(move |cx| {
 99        cx.open_window(
100            WindowOptions {
101                titlebar: Some(TitlebarOptions {
102                    title: Some("Profiler Window".into()),
103                    appears_transparent: false,
104                    traffic_light_position: None,
105                }),
106                focus: true,
107                show: true,
108                is_movable: true,
109                kind: gpui::WindowKind::Normal,
110                window_background,
111                window_decorations: None,
112                window_min_size: Some(default_bounds),
113                window_bounds: Some(WindowBounds::centered(default_bounds, cx)),
114                ..Default::default()
115            },
116            |_window, cx| ProfilerWindow::new(startup_time, Some(workspace_handle), cx),
117        )
118        .log_err();
119    });
120}
121
122struct TimingBar {
123    location: SerializedLocation,
124    start_nanos: u128,
125    duration_nanos: u128,
126    color: Hsla,
127}
128
129pub struct ProfilerWindow {
130    collector: ProfilingCollector,
131    source: ProfileSource,
132    timings: Vec<SerializedThreadTaskTimings>,
133    paused: bool,
134    display_timings: Rc<Vec<SerializedTaskTiming>>,
135    include_self_timings: ToggleState,
136    autoscroll: bool,
137    scroll_handle: UniformListScrollHandle,
138    workspace: Option<WeakEntity<Workspace>>,
139    has_remote: bool,
140    remote_now_nanos: u128,
141    remote_received_at: Option<Instant>,
142    _remote_poll_task: Option<Task<()>>,
143}
144
145impl ProfilerWindow {
146    pub fn new(
147        startup_time: Instant,
148        workspace_handle: Option<WeakEntity<Workspace>>,
149        cx: &mut App,
150    ) -> Entity<Self> {
151        cx.new(|_cx| ProfilerWindow {
152            collector: ProfilingCollector::new(startup_time),
153            source: ProfileSource::Foreground,
154            timings: Vec::new(),
155            paused: false,
156            display_timings: Rc::new(Vec::new()),
157            include_self_timings: ToggleState::Unselected,
158            autoscroll: true,
159            scroll_handle: UniformListScrollHandle::default(),
160            workspace: workspace_handle,
161            has_remote: false,
162            remote_now_nanos: 0,
163            remote_received_at: None,
164            _remote_poll_task: None,
165        })
166    }
167
168    fn poll_timings(&mut self, cx: &App) {
169        self.has_remote = self.remote_proto_client(cx).is_some();
170        match self.source {
171            ProfileSource::Foreground => {
172                let dispatcher = cx.foreground_executor().dispatcher();
173                let current_thread = dispatcher.get_current_thread_timings();
174                let deltas = self.collector.collect_unseen(vec![current_thread]);
175                self.apply_deltas(deltas);
176            }
177            ProfileSource::AllThreads => {
178                let dispatcher = cx.foreground_executor().dispatcher();
179                let all_timings = dispatcher.get_all_timings();
180                let deltas = self.collector.collect_unseen(all_timings);
181                self.apply_deltas(deltas);
182            }
183            ProfileSource::RemoteForeground | ProfileSource::RemoteAllThreads => {
184                // Remote timings arrive asynchronously via apply_remote_response.
185            }
186        }
187        self.rebuild_display_timings();
188    }
189
190    fn rebuild_display_timings(&mut self) {
191        let include_self = self.include_self_timings.selected();
192        let cutoff_nanos = self.now_nanos().saturating_sub(VISIBLE_WINDOW_NANOS);
193
194        let per_thread: Vec<Vec<SerializedTaskTiming>> = self
195            .timings
196            .iter()
197            .map(|thread| {
198                let visible = visible_tail(&thread.timings, cutoff_nanos);
199                filter_timings(visible.iter().cloned(), include_self)
200            })
201            .collect();
202        self.display_timings = Rc::new(kway_merge(per_thread));
203    }
204
205    fn now_nanos(&self) -> u128 {
206        if self.source.is_remote() {
207            let elapsed_since_poll = self
208                .remote_received_at
209                .map(|at| Instant::now().duration_since(at).as_nanos())
210                .unwrap_or(0);
211            self.remote_now_nanos + elapsed_since_poll
212        } else {
213            Instant::now()
214                .duration_since(self.collector.startup_time())
215                .as_nanos()
216        }
217    }
218
219    fn set_source(&mut self, source: ProfileSource, cx: &mut Context<Self>) {
220        if self.source == source {
221            return;
222        }
223
224        self.source = source;
225
226        self.timings.clear();
227        self.collector.reset();
228        self.display_timings = Rc::new(Vec::new());
229        self.remote_now_nanos = 0;
230        self.remote_received_at = None;
231        self.has_remote = self.remote_proto_client(cx).is_some();
232
233        if source.is_remote() {
234            self.start_remote_polling(cx);
235        } else {
236            self._remote_poll_task = None;
237        }
238    }
239
240    fn remote_proto_client(&self, cx: &App) -> Option<AnyProtoClient> {
241        let workspace = self.workspace.as_ref()?;
242        workspace
243            .read_with(cx, |workspace, cx| {
244                let project = workspace.project().read(cx);
245                let remote_client = project.remote_client()?;
246                Some(remote_client.read(cx).proto_client())
247            })
248            .log_err()
249            .flatten()
250    }
251
252    fn start_remote_polling(&mut self, cx: &mut Context<Self>) {
253        let Some(proto_client) = self.remote_proto_client(cx) else {
254            return;
255        };
256
257        let source_foreground_only = self.source.foreground_only();
258        let weak = cx.weak_entity();
259        self._remote_poll_task = Some(cx.spawn(async move |_this, cx| {
260            loop {
261                let response = proto_client
262                    .request(proto::GetRemoteProfilingData {
263                        project_id: proto::REMOTE_SERVER_PROJECT_ID,
264                        foreground_only: source_foreground_only,
265                    })
266                    .await;
267
268                match response {
269                    Ok(response) => {
270                        let ok = weak.update(&mut cx.clone(), |this, cx| {
271                            this.apply_remote_response(response);
272                            cx.notify();
273                        });
274                        if ok.is_err() {
275                            break;
276                        }
277                    }
278                    Err(error) => {
279                        Err::<(), _>(error).log_err();
280                    }
281                }
282
283                cx.background_executor().timer(REMOTE_POLL_INTERVAL).await;
284            }
285        }));
286    }
287
288    fn apply_remote_response(&mut self, response: proto::GetRemoteProfilingDataResponse) {
289        self.has_remote = true;
290        self.remote_now_nanos = response.now_nanos as u128;
291        self.remote_received_at = Some(Instant::now());
292        let deltas = response
293            .threads
294            .into_iter()
295            .map(|thread| {
296                let new_timings = thread
297                    .timings
298                    .into_iter()
299                    .map(|t| {
300                        let location = t.location.unwrap_or_default();
301                        SerializedTaskTiming {
302                            location: SerializedLocation {
303                                file: SharedString::from(location.file),
304                                line: location.line,
305                                column: location.column,
306                            },
307                            start: t.start_nanos as u128,
308                            duration: t.duration_nanos as u128,
309                        }
310                    })
311                    .collect();
312                ThreadTimingsDelta {
313                    thread_id: thread.thread_id,
314                    thread_name: thread.thread_name,
315                    new_timings,
316                }
317            })
318            .collect();
319
320        self.apply_deltas(deltas);
321        self.rebuild_display_timings();
322    }
323
324    fn apply_deltas(&mut self, deltas: Vec<ThreadTimingsDelta>) {
325        for delta in deltas {
326            append_to_thread(
327                &mut self.timings,
328                delta.thread_id,
329                delta.thread_name,
330                delta.new_timings,
331            );
332        }
333    }
334
335    fn render_source_dropdown(
336        &self,
337        window: &mut gpui::Window,
338        cx: &mut Context<Self>,
339    ) -> DropdownMenu {
340        let weak = cx.weak_entity();
341        let current_source = self.source;
342        let has_remote = self.has_remote;
343
344        let mut sources = vec![ProfileSource::Foreground, ProfileSource::AllThreads];
345        if has_remote {
346            sources.push(ProfileSource::RemoteForeground);
347            sources.push(ProfileSource::RemoteAllThreads);
348        }
349
350        DropdownMenu::new(
351            "profile-source",
352            current_source.label(),
353            ContextMenu::build(window, cx, move |mut menu, window, cx| {
354                for source in &sources {
355                    let source = *source;
356                    let weak = weak.clone();
357                    menu = menu.entry(source.label(), None, move |_, cx| {
358                        weak.update(cx, |this, cx| {
359                            this.set_source(source, cx);
360                            cx.notify();
361                        })
362                        .log_err();
363                    });
364                }
365                if let Some(index) = sources.iter().position(|s| *s == current_source) {
366                    for _ in 0..=index {
367                        menu.select_next(&Default::default(), window, cx);
368                    }
369                }
370                menu
371            }),
372        )
373    }
374
375    fn render_timing(
376        window_start_nanos: u128,
377        window_duration_nanos: u128,
378        item: TimingBar,
379        cx: &App,
380    ) -> Div {
381        let time_ms = item.duration_nanos as f32 / NANOS_PER_MS as f32;
382
383        let start_fraction = if item.start_nanos >= window_start_nanos {
384            (item.start_nanos - window_start_nanos) as f32 / window_duration_nanos as f32
385        } else {
386            0.0
387        };
388
389        let end_nanos = item.start_nanos + item.duration_nanos;
390        let end_fraction = if end_nanos >= window_start_nanos {
391            (end_nanos - window_start_nanos) as f32 / window_duration_nanos as f32
392        } else {
393            0.0
394        };
395
396        let start_fraction = start_fraction.clamp(0.0, 1.0);
397        let end_fraction = end_fraction.clamp(0.0, 1.0);
398        let bar_width = (end_fraction - start_fraction).max(0.0);
399
400        let file_str: &str = &item.location.file;
401        let basename = file_str.rsplit_once("/").unwrap_or(("", file_str)).1;
402        let basename = basename.rsplit_once("\\").unwrap_or(("", basename)).1;
403
404        let label = SharedString::from(format!(
405            "{}:{}:{}",
406            basename, item.location.line, item.location.column
407        ));
408
409        h_flex()
410            .gap_2()
411            .w_full()
412            .h(px(32.0))
413            .child(
414                div()
415                    .id(label.clone())
416                    .w(px(200.0))
417                    .flex_shrink_0()
418                    .overflow_hidden()
419                    .child(div().text_ellipsis().child(label.clone()))
420                    .tooltip(Tooltip::text(label.clone()))
421                    .on_click(move |_, _, cx| {
422                        cx.write_to_clipboard(ClipboardItem::new_string(label.to_string()))
423                    }),
424            )
425            .child(
426                div()
427                    .flex_1()
428                    .h(px(24.0))
429                    .bg(cx.theme().colors().background)
430                    .rounded_md()
431                    .p(px(2.0))
432                    .relative()
433                    .child(
434                        div()
435                            .absolute()
436                            .h_full()
437                            .rounded_sm()
438                            .bg(item.color)
439                            .left(relative(start_fraction.max(0.0)))
440                            .w(relative(bar_width)),
441                    ),
442            )
443            .child(
444                div()
445                    .min_w(px(70.))
446                    .flex_shrink_0()
447                    .text_right()
448                    .child(format!("{:.1} ms", time_ms)),
449            )
450    }
451}
452
453impl Render for ProfilerWindow {
454    fn render(
455        &mut self,
456        window: &mut gpui::Window,
457        cx: &mut gpui::Context<Self>,
458    ) -> impl gpui::IntoElement {
459        let ui_font = theme_settings::setup_ui_font(window, cx);
460        if !self.paused {
461            self.poll_timings(cx);
462            window.request_animation_frame();
463        }
464
465        let scroll_offset = self.scroll_handle.offset();
466        let max_offset = self.scroll_handle.max_offset();
467        self.autoscroll = -scroll_offset.y >= (max_offset.y - px(24.));
468        if self.autoscroll {
469            self.scroll_handle.scroll_to_bottom();
470        }
471
472        let display_timings = self.display_timings.clone();
473
474        v_flex()
475            .id("profiler")
476            .font(ui_font)
477            .w_full()
478            .h_full()
479            .bg(cx.theme().colors().surface_background)
480            .text_color(cx.theme().colors().text)
481            .child(
482                h_flex()
483                    .py_2()
484                    .px_4()
485                    .w_full()
486                    .justify_between()
487                    .child(
488                        h_flex()
489                            .gap_2()
490                            .child(self.render_source_dropdown(window, cx))
491                            .child(
492                                Button::new(
493                                    "switch-mode",
494                                    if self.paused { "Resume" } else { "Pause" },
495                                )
496                                .style(ButtonStyle::Filled)
497                                .on_click(cx.listener(
498                                    |this, _, _window, cx| {
499                                        this.paused = !this.paused;
500                                        if !this.paused && this.source.is_remote() {
501                                            this.start_remote_polling(cx);
502                                        } else if this.paused && this.source.is_remote() {
503                                            this._remote_poll_task = None;
504                                        }
505                                        cx.notify();
506                                    },
507                                )),
508                            )
509                            .child(
510                                Button::new("export-data", "Save")
511                                    .style(ButtonStyle::Filled)
512                                    .on_click(cx.listener(|this, _, _window, cx| {
513                                        let Some(workspace) = this.workspace.as_ref() else {
514                                            return;
515                                        };
516
517                                        if this.timings.iter().all(|t| t.timings.is_empty()) {
518                                            return;
519                                        }
520
521                                        let serialized = if this.source.foreground_only() {
522                                            let flat: Vec<&SerializedTaskTiming> = this
523                                                .timings
524                                                .iter()
525                                                .flat_map(|t| &t.timings)
526                                                .collect();
527                                            serde_json::to_string(&flat)
528                                        } else {
529                                            serde_json::to_string(&this.timings)
530                                        };
531
532                                        let Some(serialized) = serialized.log_err() else {
533                                            return;
534                                        };
535
536                                        let active_path = workspace
537                                            .read_with(cx, |workspace, cx| {
538                                                workspace.most_recent_active_path(cx)
539                                            })
540                                            .log_err()
541                                            .flatten()
542                                            .and_then(|p| p.parent().map(|p| p.to_owned()))
543                                            .unwrap_or_else(PathBuf::default);
544
545                                        let path = cx.prompt_for_new_path(
546                                            &active_path,
547                                            Some("performance_profile.miniprof.json"),
548                                        );
549
550                                        cx.background_spawn(async move {
551                                            let path = path.await;
552                                            let path =
553                                                path.log_err().and_then(|p| p.log_err()).flatten();
554
555                                            let Some(path) = path else {
556                                                return;
557                                            };
558
559                                            smol::fs::write(path, &serialized).await.log_err();
560                                        })
561                                        .detach();
562                                    })),
563                            ),
564                    )
565                    .child(
566                        Checkbox::new("include-self", self.include_self_timings)
567                            .label("Include profiler timings")
568                            .on_click(cx.listener(|this, checked, _window, cx| {
569                                this.include_self_timings = *checked;
570                                cx.notify();
571                            })),
572                    ),
573            )
574            .when(!display_timings.is_empty(), |div| {
575                let now_nanos = self.now_nanos();
576
577                let window_start_nanos = now_nanos.saturating_sub(VISIBLE_WINDOW_NANOS);
578                let window_duration_nanos = VISIBLE_WINDOW_NANOS;
579
580                div.child(Divider::horizontal()).child(
581                    v_flex()
582                        .id("timings.bars")
583                        .w_full()
584                        .h_full()
585                        .gap_2()
586                        .child(
587                            uniform_list("list", display_timings.len(), {
588                                let timings = display_timings.clone();
589                                move |visible_range, _, cx| {
590                                    let mut items = vec![];
591                                    for i in visible_range {
592                                        let timing = &timings[i];
593                                        items.push(Self::render_timing(
594                                            window_start_nanos,
595                                            window_duration_nanos,
596                                            TimingBar {
597                                                location: timing.location.clone(),
598                                                start_nanos: timing.start,
599                                                duration_nanos: timing.duration,
600                                                color: cx.theme().accents().color_for_index(
601                                                    location_color_index(&timing.location),
602                                                ),
603                                            },
604                                            cx,
605                                        ));
606                                    }
607                                    items
608                                }
609                            })
610                            .p_4()
611                            .on_scroll_wheel(cx.listener(|this, _, _, cx| {
612                                this.autoscroll = false;
613                                cx.notify();
614                            }))
615                            .track_scroll(&self.scroll_handle)
616                            .size_full(),
617                        )
618                        .custom_scrollbars(
619                            Scrollbars::always_visible(ScrollAxes::Vertical)
620                                .tracked_scroll_handle(&self.scroll_handle),
621                            window,
622                            cx,
623                        ),
624                )
625            })
626    }
627}
628
629const MAX_VISIBLE_PER_THREAD: usize = 10_000;
630
631fn visible_tail(timings: &[SerializedTaskTiming], cutoff_nanos: u128) -> &[SerializedTaskTiming] {
632    let len = timings.len();
633    let limit = len.min(MAX_VISIBLE_PER_THREAD);
634    let search_start = len - limit;
635    let tail = &timings[search_start..];
636
637    let mut first_visible = 0;
638    for (i, timing) in tail.iter().enumerate().rev() {
639        if timing.start + timing.duration < cutoff_nanos {
640            first_visible = i + 1;
641            break;
642        }
643    }
644    &tail[first_visible..]
645}
646
647fn filter_timings(
648    timings: impl Iterator<Item = SerializedTaskTiming>,
649    include_self: bool,
650) -> Vec<SerializedTaskTiming> {
651    timings
652        .filter(|t| t.duration / NANOS_PER_MS >= 1)
653        .filter(|t| include_self || !t.location.file.ends_with("miniprofiler_ui.rs"))
654        .collect()
655}
656
657fn location_color_index(location: &SerializedLocation) -> u32 {
658    let mut hasher = DefaultHasher::new();
659    location.file.hash(&mut hasher);
660    location.line.hash(&mut hasher);
661    location.column.hash(&mut hasher);
662    hasher.finish() as u32
663}
664
665/// Merge K sorted `Vec<SerializedTaskTiming>` into a single sorted vec.
666/// Each input vec must already be sorted by `start`.
667fn kway_merge(lists: Vec<Vec<SerializedTaskTiming>>) -> Vec<SerializedTaskTiming> {
668    let total_len: usize = lists.iter().map(|l| l.len()).sum();
669    let mut result = Vec::with_capacity(total_len);
670    let mut cursors = vec![0usize; lists.len()];
671
672    loop {
673        let mut min_start = u128::MAX;
674        let mut min_list = None;
675
676        for (list_idx, list) in lists.iter().enumerate() {
677            let cursor = cursors[list_idx];
678            if let Some(timing) = list.get(cursor) {
679                if timing.start < min_start {
680                    min_start = timing.start;
681                    min_list = Some(list_idx);
682                }
683            }
684        }
685
686        match min_list {
687            Some(idx) => {
688                result.push(lists[idx][cursors[idx]].clone());
689                cursors[idx] += 1;
690            }
691            None => break,
692        }
693    }
694
695    result
696}
697
698fn append_to_thread(
699    threads: &mut Vec<SerializedThreadTaskTimings>,
700    thread_id: u64,
701    thread_name: Option<String>,
702    new_timings: Vec<SerializedTaskTiming>,
703) {
704    if let Some(existing) = threads.iter_mut().find(|t| t.thread_id == thread_id) {
705        existing.timings.extend(new_timings);
706        if existing.thread_name.is_none() {
707            existing.thread_name = thread_name;
708        }
709    } else {
710        threads.push(SerializedThreadTaskTimings {
711            thread_name,
712            thread_id,
713            timings: new_timings,
714        });
715    }
716}