use std::vec;
use super::{SelectedFrames, ERROR_COLOR, HOVER_COLOR};
use crate::filter::Filter;
use egui::*;
use indexmap::IndexMap;
use puffin::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum SortBy {
Time,
Name,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Sorting {
pub sort_by: SortBy,
pub reversed: bool,
}
impl Default for Sorting {
fn default() -> Self {
Self {
sort_by: SortBy::Time,
reversed: false,
}
}
}
impl Sorting {
fn sort(self, mut threads: Vec<ThreadInfo>) -> Vec<ThreadInfo> {
match self.sort_by {
SortBy::Time => {
threads.sort_by_key(|info| info.start_time_ns);
}
SortBy::Name => {
threads.sort_by(|a, b| natord::compare_ignore_case(&a.name, &b.name));
}
}
if self.reversed {
threads.reverse();
}
threads
}
fn ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label("Sort threads by:");
let dir = if self.reversed { '⬆' } else { '⬇' };
for &sort_by in &[SortBy::Time, SortBy::Name] {
let selected = self.sort_by == sort_by;
let label = if selected {
format!("{sort_by:?} {dir}")
} else {
format!("{sort_by:?}")
};
if ui.add(egui::RadioButton::new(selected, label)).clicked() {
if selected {
self.reversed = !self.reversed;
} else {
self.sort_by = sort_by;
self.reversed = false;
}
}
}
});
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct ThreadVisualizationSettings {
flamegraph_collapse: bool,
flamegraph_show: bool,
}
impl Default for ThreadVisualizationSettings {
fn default() -> Self {
Self {
flamegraph_collapse: false,
flamegraph_show: true,
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct Options {
pub canvas_width_ns: f32,
pub sideways_pan_in_points: f32,
pub cull_width: f32,
pub min_width: f32,
pub rect_height: f32,
pub spacing: f32,
pub rounding: f32,
pub frame_list_height: f32,
pub frame_width: f32,
pub merge_scopes: bool,
pub sorting: Sorting,
pub flamegraph_threads: IndexMap<String, ThreadVisualizationSettings>,
#[cfg_attr(feature = "serde", serde(skip))]
filter: Filter,
#[cfg_attr(feature = "serde", serde(skip))]
zoom_to_relative_ns_range: Option<(f64, (NanoSecond, NanoSecond))>,
}
impl Default for Options {
fn default() -> Self {
Self {
canvas_width_ns: 0.0,
sideways_pan_in_points: 0.0,
cull_width: 0.0, min_width: 1.0,
rect_height: 16.0,
spacing: 4.0,
rounding: 4.0,
frame_list_height: 48.0,
frame_width: 10.0,
merge_scopes: true,
sorting: Default::default(),
filter: Default::default(),
zoom_to_relative_ns_range: None,
flamegraph_threads: IndexMap::new(),
}
}
}
struct Info {
ctx: egui::Context,
canvas: Rect,
response: Response,
painter: egui::Painter,
text_height: f32,
start_ns: NanoSecond,
stop_ns: NanoSecond,
num_frames: usize,
font_id: FontId,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum PaintResult {
Culled,
Hovered,
Normal,
}
impl Info {
fn point_from_ns(&self, options: &Options, ns: NanoSecond) -> f32 {
self.canvas.min.x
+ options.sideways_pan_in_points
+ self.canvas.width() * ((ns - self.start_ns) as f32) / options.canvas_width_ns
}
}
pub fn ui(ui: &mut egui::Ui, options: &mut Options, frames: &SelectedFrames) {
puffin::profile_function!();
let mut reset_view = false;
let num_frames = frames.frames.len();
{
let num_frames_id = ui.id().with("num_frames");
let num_frames_last_frame = ui
.memory()
.data
.get_temp::<usize>(num_frames_id)
.unwrap_or_default();
if num_frames_last_frame != num_frames && !options.merge_scopes {
reset_view = true;
}
ui.memory().data.insert_temp(num_frames_id, num_frames);
}
ui.columns(2, |ui| {
ui[0].horizontal(|ui| {
ui.colored_label(ui.visuals().widgets.inactive.text_color(), "❓")
.on_hover_text(
"Drag to pan.\n\
Zoom: Ctrl/cmd + scroll, or drag with secondary mouse button.\n\
Click on a scope to zoom to it.\n\
Double-click to reset view.\n\
Press spacebar to pause/resume.",
);
ui.separator();
ui.horizontal(|ui| {
let changed = ui
.checkbox(&mut options.merge_scopes, "Merge children with same ID")
.changed();
if changed && num_frames > 1 {
reset_view = true;
}
});
ui.separator();
options.sorting.ui(ui);
});
options.filter.ui(&mut ui[0]);
ui[1].collapsing("Visible Threads", |ui| {
egui::ScrollArea::vertical()
.max_height(150.0)
.id_source("f")
.show(ui, |ui| {
for f in frames.threads.keys() {
let entry = options
.flamegraph_threads
.entry(f.name.clone())
.or_insert_with(ThreadVisualizationSettings::default);
ui.checkbox(&mut entry.flamegraph_show, f.name.clone());
}
});
});
});
ui.separator();
Frame::dark_canvas(ui.style()).show(ui, |ui| {
let available_height = ui.max_rect().bottom() - ui.min_rect().bottom();
ScrollArea::vertical().show(ui, |ui| {
let mut canvas = ui.available_rect_before_wrap();
canvas.max.y = f32::INFINITY;
let response = ui.interact(canvas, ui.id(), Sense::click_and_drag());
let (min_ns, max_ns) = if options.merge_scopes {
frames.merged_range_ns
} else {
frames.raw_range_ns
};
let info = Info {
ctx: ui.ctx().clone(),
canvas,
response,
painter: ui.painter_at(canvas),
text_height: 15.0, start_ns: min_ns,
stop_ns: max_ns,
num_frames: frames.frames.len(),
font_id: TextStyle::Body.resolve(ui.style()),
};
if reset_view {
options.zoom_to_relative_ns_range =
Some((info.ctx.input().time, (0, info.stop_ns - info.start_ns)));
}
interact_with_canvas(options, &info.response, &info);
let where_to_put_timeline = info.painter.add(Shape::Noop);
let max_y = ui_canvas(options, &info, frames, (min_ns, max_ns));
let mut used_rect = canvas;
used_rect.max.y = max_y;
used_rect.max.y = used_rect.max.y.max(used_rect.min.y + available_height);
let timeline = paint_timeline(&info, used_rect, options, min_ns);
info.painter
.set(where_to_put_timeline, Shape::Vec(timeline));
ui.allocate_rect(used_rect, Sense::hover());
});
});
}
fn ui_canvas(
options: &mut Options,
info: &Info,
frames: &SelectedFrames,
(min_ns, max_ns): (NanoSecond, NanoSecond),
) -> f32 {
puffin::profile_function!();
if options.canvas_width_ns <= 0.0 {
options.canvas_width_ns = (max_ns - min_ns) as f32;
options.zoom_to_relative_ns_range = None;
}
let mut cursor_y = info.canvas.top();
cursor_y += info.text_height;
let threads = frames.threads.keys().cloned().collect();
let threads = options.sorting.sort(threads);
for thread_info in threads {
let thread_visualization = options
.flamegraph_threads
.entry(thread_info.name.clone())
.or_insert_with(ThreadVisualizationSettings::default);
if !thread_visualization.flamegraph_show {
continue;
}
cursor_y += 2.0;
let line_y = cursor_y;
cursor_y += 2.0;
let text_pos = pos2(info.canvas.min.x, cursor_y);
paint_thread_info(
info,
&thread_info,
text_pos,
&mut thread_visualization.flamegraph_collapse,
);
info.painter.line_segment(
[
pos2(info.canvas.min.x, line_y),
pos2(info.canvas.max.x, line_y),
],
Stroke::new(1.0, Rgba::from_white_alpha(0.5)),
);
cursor_y += info.text_height;
if !thread_visualization.flamegraph_collapse {
let mut paint_streams = || -> Result<()> {
if options.merge_scopes {
for merge in &frames.threads[&thread_info].merged_scopes {
paint_merge_scope(info, options, 0, merge, 0, cursor_y)?;
}
} else {
for stream_info in &frames.threads[&thread_info].streams {
let top_scopes =
Reader::from_start(&stream_info.stream).read_top_scopes()?;
for scope in top_scopes {
paint_scope(info, options, &stream_info.stream, &scope, 0, cursor_y)?;
}
}
}
Ok(())
};
if let Err(err) = paint_streams() {
let text = format!("Profiler stream error: {err:?}");
info.painter.text(
pos2(info.canvas.min.x, cursor_y),
Align2::LEFT_TOP,
text,
info.font_id.clone(),
ERROR_COLOR,
);
}
let max_depth = frames.threads[&thread_info].max_depth;
cursor_y += max_depth as f32 * (options.rect_height + options.spacing);
}
cursor_y += info.text_height; }
cursor_y
}
fn interact_with_canvas(options: &mut Options, response: &Response, info: &Info) {
if response.drag_delta().x != 0.0 {
options.sideways_pan_in_points += response.drag_delta().x;
options.zoom_to_relative_ns_range = None;
}
if response.hovered() {
if info.ctx.input().scroll_delta.x != 0.0 {
options.sideways_pan_in_points += info.ctx.input().scroll_delta.x;
options.zoom_to_relative_ns_range = None;
}
let mut zoom_factor = info.ctx.input().zoom_delta_2d().x;
if response.dragged_by(PointerButton::Secondary) {
zoom_factor *= (response.drag_delta().y * 0.01).exp();
}
if zoom_factor != 1.0 {
options.canvas_width_ns /= zoom_factor;
if let Some(mouse_pos) = response.hover_pos() {
let zoom_center = mouse_pos.x - info.canvas.min.x;
options.sideways_pan_in_points =
(options.sideways_pan_in_points - zoom_center) * zoom_factor + zoom_center;
}
options.zoom_to_relative_ns_range = None;
}
}
if response.double_clicked() {
options.zoom_to_relative_ns_range =
Some((info.ctx.input().time, (0, info.stop_ns - info.start_ns)));
}
if let Some((start_time, (start_ns, end_ns))) = options.zoom_to_relative_ns_range {
const ZOOM_DURATION: f32 = 0.75;
let t = ((info.ctx.input().time - start_time) as f32 / ZOOM_DURATION).min(1.0);
let canvas_width = response.rect.width();
let target_canvas_width_ns = (end_ns - start_ns) as f32;
let target_pan_in_points = -canvas_width * start_ns as f32 / target_canvas_width_ns;
options.canvas_width_ns = lerp(
options.canvas_width_ns.recip()..=target_canvas_width_ns.recip(),
t,
)
.recip();
options.sideways_pan_in_points =
lerp(options.sideways_pan_in_points..=target_pan_in_points, t);
if t >= 1.0 {
options.zoom_to_relative_ns_range = None;
}
info.ctx.request_repaint();
}
}
fn paint_timeline(
info: &Info,
canvas: Rect,
options: &Options,
start_ns: NanoSecond,
) -> Vec<egui::Shape> {
let mut shapes = vec![];
if options.canvas_width_ns <= 0.0 {
return shapes;
}
let alpha_multiplier = if options.filter.is_empty() { 0.3 } else { 0.1 };
let max_lines = canvas.width() / 4.0;
let mut grid_spacing_ns = 1_000;
while options.canvas_width_ns / (grid_spacing_ns as f32) > max_lines {
grid_spacing_ns *= 10;
}
let num_tiny_lines = options.canvas_width_ns / (grid_spacing_ns as f32);
let zoom_factor = remap_clamp(num_tiny_lines, (0.1 * max_lines)..=max_lines, 1.0..=0.0);
let zoom_factor = zoom_factor * zoom_factor;
let big_alpha = remap_clamp(zoom_factor, 0.0..=1.0, 0.5..=1.0);
let medium_alpha = remap_clamp(zoom_factor, 0.0..=1.0, 0.1..=0.5);
let tiny_alpha = remap_clamp(zoom_factor, 0.0..=1.0, 0.0..=0.1);
let mut grid_ns = 0;
loop {
let line_x = info.point_from_ns(options, start_ns + grid_ns);
if line_x > canvas.max.x {
break;
}
if canvas.min.x <= line_x {
let big_line = grid_ns % (grid_spacing_ns * 100) == 0;
let medium_line = grid_ns % (grid_spacing_ns * 10) == 0;
let line_alpha = if big_line {
big_alpha
} else if medium_line {
medium_alpha
} else {
tiny_alpha
};
shapes.push(egui::Shape::line_segment(
[pos2(line_x, canvas.min.y), pos2(line_x, canvas.max.y)],
Stroke::new(1.0, Rgba::from_white_alpha(line_alpha * alpha_multiplier)),
));
let text_alpha = if big_line {
medium_alpha
} else if medium_line {
tiny_alpha
} else {
0.0
};
if text_alpha > 0.0 {
let text = grid_text(grid_ns);
let text_x = line_x + 4.0;
let text_color = Rgba::from_white_alpha((text_alpha * 2.0).min(1.0)).into();
shapes.push(egui::Shape::text(
&info.painter.fonts(),
pos2(text_x, canvas.min.y),
Align2::LEFT_TOP,
&text,
info.font_id.clone(),
text_color,
));
shapes.push(egui::Shape::text(
&info.painter.fonts(),
pos2(text_x, canvas.max.y - info.text_height),
Align2::LEFT_TOP,
&text,
info.font_id.clone(),
text_color,
));
}
}
grid_ns += grid_spacing_ns;
}
shapes
}
fn grid_text(grid_ns: NanoSecond) -> String {
let grid_ms = to_ms(grid_ns);
if grid_ns % 1_000_000 == 0 {
format!("{grid_ms:.0} ms")
} else if grid_ns % 100_000 == 0 {
format!("{grid_ms:.1} ms")
} else if grid_ns % 10_000 == 0 {
format!("{grid_ms:.2} ms")
} else {
format!("{grid_ms:.3} ms")
}
}
fn paint_record(
info: &Info,
options: &mut Options,
prefix: &str,
suffix: &str,
record: &Record<'_>,
top_y: f32,
) -> PaintResult {
let start_x = info.point_from_ns(options, record.start_ns);
let stop_x = info.point_from_ns(options, record.stop_ns());
if info.canvas.max.x < start_x
|| stop_x < info.canvas.min.x
|| stop_x - start_x < options.cull_width
{
return PaintResult::Culled;
}
let bottom_y = top_y + options.rect_height;
let rect = Rect::from_min_max(pos2(start_x, top_y), pos2(stop_x, bottom_y));
let is_hovered = if let Some(mouse_pos) = info.response.hover_pos() {
rect.contains(mouse_pos)
} else {
false
};
if info.response.double_clicked() {
if let Some(mouse_pos) = info.response.interact_pointer_pos() {
if rect.contains(mouse_pos) {
options.filter.set_filter(record.id.to_string());
}
}
} else if is_hovered && info.response.clicked() {
options.zoom_to_relative_ns_range = Some((
info.ctx.input().time,
(
record.start_ns - info.start_ns,
record.stop_ns() - info.start_ns,
),
));
}
let mut rect_color = if is_hovered {
HOVER_COLOR
} else {
color_from_duration(record.duration_ns)
};
let mut min_width = options.min_width;
if !options.filter.is_empty() {
if options.filter.include(record.id) {
min_width *= 2.0; } else {
rect_color = rect_color.multiply(0.075); }
}
if rect.width() <= min_width {
info.painter.line_segment(
[rect.center_top(), rect.center_bottom()],
egui::Stroke::new(min_width, rect_color),
);
} else {
info.painter.rect_filled(rect, options.rounding, rect_color);
}
let wide_enough_for_text = stop_x - start_x > 32.0;
if wide_enough_for_text {
let painter = info.painter.with_clip_rect(rect.intersect(info.canvas));
let duration_ms = to_ms(record.duration_ns);
let text = if record.data.is_empty() {
format!("{}{} {:6.3} ms {}", prefix, record.id, duration_ms, suffix)
} else {
format!(
"{}{} {:?} {:6.3} ms {}",
prefix, record.id, record.data, duration_ms, suffix
)
};
let pos = pos2(
start_x + 4.0,
top_y + 0.5 * (options.rect_height - info.text_height),
);
let pos = painter.round_pos_to_pixels(pos);
const TEXT_COLOR: Color32 = Color32::BLACK;
painter.text(
pos,
Align2::LEFT_TOP,
text,
info.font_id.clone(),
TEXT_COLOR,
);
}
if is_hovered {
PaintResult::Hovered
} else {
PaintResult::Normal
}
}
fn color_from_duration(ns: NanoSecond) -> Rgba {
let ms = to_ms(ns) as f32;
let b = remap_clamp(ms, 0.0..=5.0, 1.0..=0.3);
let r = remap_clamp(ms, 0.0..=10.0, 0.5..=0.8);
let g = remap_clamp(ms, 10.0..=33.0, 0.1..=0.8);
let a = 0.9;
Rgba::from_rgb(r, g, b) * a
}
fn to_ms(ns: NanoSecond) -> f64 {
ns as f64 * 1e-6
}
fn paint_scope(
info: &Info,
options: &mut Options,
stream: &Stream,
scope: &Scope<'_>,
depth: usize,
min_y: f32,
) -> Result<PaintResult> {
let top_y = min_y + (depth as f32) * (options.rect_height + options.spacing);
let result = paint_record(info, options, "", "", &scope.record, top_y);
if result != PaintResult::Culled {
let mut num_children = 0;
for child_scope in Reader::with_offset(stream, scope.child_begin_position)? {
paint_scope(info, options, stream, &child_scope?, depth + 1, min_y)?;
num_children += 1;
}
if result == PaintResult::Hovered {
egui::show_tooltip_at_pointer(&info.ctx, Id::new("puffin_profiler_tooltip"), |ui| {
ui.monospace(format!("id: {}", scope.record.id));
if !scope.record.location.is_empty() {
ui.monospace(format!("location: {}", scope.record.location));
}
if !scope.record.data.is_empty() {
ui.monospace(format!("data: {}", scope.record.data));
}
ui.monospace(format!(
"duration: {:7.3} ms",
to_ms(scope.record.duration_ns)
));
ui.monospace(format!("children: {num_children}"));
});
}
}
Ok(result)
}
fn paint_merge_scope(
info: &Info,
options: &mut Options,
ns_offset: NanoSecond,
merge: &MergeScope<'_>,
depth: usize,
min_y: f32,
) -> Result<PaintResult> {
let top_y = min_y + (depth as f32) * (options.rect_height + options.spacing);
let prefix = if info.num_frames <= 1 {
if merge.num_pieces <= 1 {
String::default()
} else {
format!("{}x ", merge.num_pieces)
}
} else {
let is_integral = merge.num_pieces % info.num_frames == 0;
if is_integral {
format!("{}x ", merge.num_pieces / info.num_frames)
} else {
format!("{:.2}x ", merge.num_pieces as f64 / info.num_frames as f64)
}
};
let suffix = if info.num_frames <= 1 {
""
} else {
"per frame"
};
let record = Record {
start_ns: ns_offset + merge.relative_start_ns,
duration_ns: merge.duration_per_frame_ns,
id: &merge.id,
location: &merge.location,
data: &merge.data,
};
let result = paint_record(info, options, &prefix, suffix, &record, top_y);
if result != PaintResult::Culled {
for child in &merge.children {
paint_merge_scope(info, options, record.start_ns, child, depth + 1, min_y)?;
}
if result == PaintResult::Hovered {
egui::show_tooltip_at_pointer(&info.ctx, Id::new("puffin_profiler_tooltip"), |ui| {
merge_scope_tooltip(ui, merge, info.num_frames);
});
}
}
Ok(result)
}
fn merge_scope_tooltip(ui: &mut egui::Ui, merge: &MergeScope<'_>, num_frames: usize) {
#![allow(clippy::collapsible_else_if)]
ui.monospace(format!("id: {}", merge.id));
if !merge.location.is_empty() {
ui.monospace(format!("location: {}", merge.location));
}
if !merge.data.is_empty() {
ui.monospace(format!("data: {}", merge.data));
}
ui.add_space(8.0);
if num_frames <= 1 {
if merge.num_pieces <= 1 {
ui.monospace(format!(
"duration: {:7.3} ms",
to_ms(merge.duration_per_frame_ns)
));
} else {
ui.monospace(format!("sum of {} scopes", merge.num_pieces));
ui.monospace(format!(
"total: {:7.3} ms",
to_ms(merge.duration_per_frame_ns)
));
ui.monospace(format!(
"mean: {:7.3} ms",
to_ms(merge.duration_per_frame_ns) / (merge.num_pieces as f64),
));
ui.monospace(format!("max: {:7.3} ms", to_ms(merge.max_duration_ns)));
}
} else {
ui.monospace(format!(
"{} calls over all {} frames",
merge.num_pieces, num_frames
));
if merge.num_pieces == num_frames {
ui.monospace("1 call / frame");
} else if merge.num_pieces % num_frames == 0 {
ui.monospace(format!("{} calls / frame", merge.num_pieces / num_frames));
} else {
ui.monospace(format!(
"{:.3} calls / frame",
merge.num_pieces as f64 / num_frames as f64
));
}
ui.monospace(format!(
"{:7.3} ms / frame",
to_ms(merge.duration_per_frame_ns)
));
ui.monospace(format!(
"{:7.3} ms / call",
to_ms(merge.total_duration_ns) / (merge.num_pieces as f64),
));
ui.monospace(format!(
"{:7.3} ms for slowest call",
to_ms(merge.max_duration_ns)
));
}
}
fn paint_thread_info(info: &Info, thread: &ThreadInfo, pos: Pos2, collapsed: &mut bool) {
let collapsed_symbol = if *collapsed { "⏵" } else { "⏷" };
let galley = info.ctx.fonts().layout_no_wrap(
format!("{} {}", collapsed_symbol, thread.name.clone()),
info.font_id.clone(),
Rgba::from_white_alpha(0.9).into(),
);
let rect = Rect::from_min_size(pos, galley.size());
let is_hovered = if let Some(mouse_pos) = info.response.hover_pos() {
rect.contains(mouse_pos)
} else {
false
};
let text_color = if is_hovered {
Color32::WHITE
} else {
Color32::from_white_alpha(229)
};
let back_color = if is_hovered {
Color32::from_black_alpha(100)
} else {
Color32::BLACK
};
info.painter.rect_filled(rect.expand(2.0), 0.0, back_color);
info.painter.galley_with_color(rect.min, galley, text_color);
if is_hovered && info.response.clicked() {
*collapsed = !(*collapsed);
}
}