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