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}