miniprofiler_ui.rs

  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}