From cd790cbf7e78de27f190f6f07723ea5cda9056db Mon Sep 17 00:00:00 2001 From: Mark Kalsbeek Date: Tue, 16 Dec 2025 23:20:34 +0100 Subject: [PATCH] polish the rendering a bit, add a macro --- README.md | 32 ++--- examples/bouncing_ball.rs | 8 +- teleprof-macros/src/lib.rs | 17 ++- teleprof/src/lib.rs | 276 +++++++++++++++++++++++++++---------- 4 files changed, 232 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 9928ad4..29b6085 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight, debug-only telemetry profiler for Rust applications. Shows thread activity and call stack hierarchy in real-time. -Inspired by RAD Telemetry - built in ~400 LOC with minimal dependencies. +Inspired by RAD Telemetry - built in ~1200 LOC with 'minimal' dependencies (for a Rust project). ## Features @@ -15,10 +15,14 @@ Inspired by RAD Telemetry - built in ~400 LOC with minimal dependencies. ## Dependencies -Only 3 dependencies (~15 total including transitive): +Only 7 dependencies (~70 total including transitive): - `minifb` - Window and framebuffer - `crossbeam-channel` - Lock-free MPSC - `once_cell` - Lazy statics +- `fontdue` +- `procmacro2` +- `syn` +- `quote` ## Usage @@ -26,7 +30,7 @@ Only 3 dependencies (~15 total including transitive): ```toml [dependencies] -teleprof = { path = "../teleprof" } # or from crates.io when published +teleprof = { path = "../teleprof" } ``` ### In your code: @@ -99,26 +103,6 @@ let work = || { - **Separate window**: Doesn't interfere with your app's rendering - **Simple API**: Just `span!("name")` and you're done -## Example Output - -``` -┌─────────────────────────────────────┐ -│ Icicle Graph (Call Stack) │ -│ ┌──────────────────────────┐ │ -│ │ frame_work │ │ -│ ├──────────┬───────────────┤ │ -│ │ physics │ render │ │ -│ ├────┬─────┤ │ │ -│ │ w0 │ w1 │ │ │ -│ └────┴─────┴───────────────┘ │ -├─────────────────────────────────────┤ -│ Thread Timeline │ -│ Main: ████████████████████ │ -│ Work 0: ░░██████░░░░░░░░░░░ │ -│ Work 1: ░░░░░░██████░░░░░░░ │ -└─────────────────────────────────────┘ -``` - ## Examples Run the included examples: @@ -138,4 +122,4 @@ The bouncing ball example demonstrates: ## License -MIT / Apache-2.0 (choose whichever you prefer) +??? diff --git a/examples/bouncing_ball.rs b/examples/bouncing_ball.rs index 7331f7b..27947f8 100644 --- a/examples/bouncing_ball.rs +++ b/examples/bouncing_ball.rs @@ -32,6 +32,7 @@ impl Ball { } } +#[instrument] fn main() { // Start the telemetry window teleprof::start(); @@ -49,7 +50,7 @@ fn main() { println!("- Flame graph rendering (all depth-N spans on row N)"); println!("- instrument_calls macro for automatic call instrumentation"); println!("- Runtime deduplication preventing double-wrapping"); - println!(); + println!(); let mut window = Window::new( "Bouncing Ball", @@ -102,6 +103,7 @@ fn main_frame( teleprof::set_thread_name(format!("ColorPicker-{}", id)); pick_new_color(ball_clone); }); + let _ = COLOR_PICKER_COUNTER.fetch_update(Ordering::Relaxed,Ordering::Relaxed,|cnt| Some(cnt -1)); } // Render - also instrumented, so dedup will handle it @@ -118,7 +120,7 @@ fn main_frame( } } -#[instrument] + fn update_physics(ball: &Arc>, dt: f32) -> bool { let mut ball = ball.lock().unwrap(); @@ -163,7 +165,6 @@ fn pick_new_color(ball: Arc>) { } // Using instrument_calls here too to see the breakdown of rendering -#[instrument_calls] fn render(ball: &Arc>, framebuffer: &mut [u32]) { clear_background(framebuffer); draw_ball(ball, framebuffer); @@ -203,7 +204,6 @@ fn submit_frame() { thread::sleep(Duration::from_millis(2)); } -#[instrument] fn print_status(ball: &Arc>, frame: u32) { let ball = ball.lock().unwrap(); println!( diff --git a/teleprof-macros/src/lib.rs b/teleprof-macros/src/lib.rs index 6cd318a..5c02591 100644 --- a/teleprof-macros/src/lib.rs +++ b/teleprof-macros/src/lib.rs @@ -66,7 +66,22 @@ pub fn instrument_calls(_args: TokenStream, input: TokenStream) -> TokenStream { let mut visitor = CallInstrumenter; visitor.visit_block_mut(&mut input_fn.block); - TokenStream::from(quote! { #input_fn }) + // Add an outer span for the function itself + let fn_name = input_fn.sig.ident.to_string(); + let fn_vis = &input_fn.vis; + let fn_sig = &input_fn.sig; + let fn_block = &input_fn.block; + let fn_attrs = &input_fn.attrs; + + let instrumented = quote! { + #(#fn_attrs)* + #fn_vis #fn_sig { + let _guard = ::teleprof::SpanGuard::new(#fn_name); + #fn_block + } + }; + + TokenStream::from(instrumented) } struct CallInstrumenter; diff --git a/teleprof/src/lib.rs b/teleprof/src/lib.rs index ab7cf71..d0285cd 100644 --- a/teleprof/src/lib.rs +++ b/teleprof/src/lib.rs @@ -505,34 +505,119 @@ mod window { view.hovered_span = None; } } - + fn update_hover(view: &mut ViewState, buffer: &RingBuffer, earliest: Instant, mx: f32, my: f32, icicle_height: usize) { view.hovered_span = None; - if my >= icicle_height as f32 { - return; // Only hover in icicle view for now - } - let mouse_time = view.icicle_time_offset + (mx as f64 / view.icicle_time_scale); - // Find span under cursor - for span in buffer.iter() { - let start_time = (span.start - earliest).as_secs_f64(); - let end_time = (span.end - earliest).as_secs_f64(); + if my < icicle_height as f32 { + // Icicle view hover + let row_height = 24; + let scroll_offset = view.icicle_scroll_y as i32; + let adjusted_y = my as i32 + scroll_offset; + let mouse_depth = (adjusted_y / row_height as i32).max(0) as usize; - if mouse_time >= start_time && mouse_time <= end_time { - let duration_us = (end_time - start_time) * 1_000_000.0; - view.hovered_span = Some(HoveredSpan { - name: span.name, - duration_us, - thread_id: span.thread_id, - start_time, - }); - break; // Take first match (could be improved to find best match by y-position) + // Collect all spans and calculate their depths + let spans: Vec<_> = buffer.iter().collect(); + let depths = calculate_span_depths(&spans); + + // Find span under cursor that matches both time AND depth + for span in buffer.iter() { + if let Some(&span_depth) = depths.get(&span.span_id) { + if span_depth != mouse_depth { + continue; + } + + let start_time = (span.start - earliest).as_secs_f64(); + let end_time = (span.end - earliest).as_secs_f64(); + + if mouse_time >= start_time && mouse_time <= end_time { + let duration_us = (end_time - start_time) * 1_000_000.0; + view.hovered_span = Some(HoveredSpan { + name: span.name, + duration_us, + thread_id: span.thread_id, + start_time, + }); + break; + } + } + } + } else { + // Timeline view hover + let timeline_y = my - icicle_height as f32; + + // Collect all spans and group by thread + let spans: Vec<_> = buffer.iter().collect(); + let mut threads: HashMap> = HashMap::new(); + for span in &spans { + threads.entry(span.thread_id).or_default().push(*span); + } + + let mut thread_ids: Vec<_> = threads.keys().copied().collect(); + thread_ids.sort_unstable(); + let num_threads = thread_ids.len(); + + if num_threads > 0 { + let timeline_height = icicle_height; // Assuming timeline takes remaining half + let row_height = (timeline_height / num_threads.max(1)).max(20); + let mouse_thread_row = (timeline_y as usize) / row_height; + + if mouse_thread_row < thread_ids.len() { + let thread_id = thread_ids[mouse_thread_row]; + let thread_spans = &threads[&thread_id]; + + // Find span at this time in this thread + for span in thread_spans { + let start_time = (span.start - earliest).as_secs_f64(); + let end_time = (span.end - earliest).as_secs_f64(); + + if mouse_time >= start_time && mouse_time <= end_time { + let duration_us = (end_time - start_time) * 1_000_000.0; + view.hovered_span = Some(HoveredSpan { + name: span.name, + duration_us, + thread_id: span.thread_id, + start_time, + }); + break; + } + } + } } } } + fn calculate_span_depths(spans: &[&CompletedSpan]) -> HashMap { + let mut children: HashMap> = HashMap::new(); + let mut depths: HashMap = HashMap::new(); + let mut roots = Vec::new(); + + for span in spans { + if let Some(parent) = span.parent_id { + children.entry(parent).or_default().push(span); + } else { + roots.push(span.span_id); + depths.insert(span.span_id, 0); + } + } + + // Compute depths for all spans using BFS + let mut queue: Vec = roots.clone(); + while let Some(span_id) = queue.pop() { + let current_depth = depths[&span_id]; + if let Some(child_list) = children.get(&span_id) { + for child in child_list { + depths.insert(child.span_id, current_depth + 1); + queue.push(child.span_id); + } + } + } + + depths +} + fn render_frame( framebuffer: &mut [u32], width: usize, @@ -662,10 +747,31 @@ mod window { let tooltip_w = 280; let tooltip_h = 80; let padding = 8; + let margin = 10; // Margin from screen edges + let offset = 20; // Offset from cursor - // Position tooltip near cursor but keep it on screen - let tooltip_x = (view.mouse_x as usize + 20).min(width.saturating_sub(tooltip_w + 10)); - let tooltip_y = (view.mouse_y as usize + 20).min(height.saturating_sub(tooltip_h + 10)); + // Calculate initial position (offset from cursor) + let mut tooltip_x = view.mouse_x as i32 + offset; + let mut tooltip_y = view.mouse_y as i32 + offset; + + // Check if tooltip would go off right edge + if tooltip_x + tooltip_w as i32 + margin > width as i32 { + // Position to the left of cursor instead + tooltip_x = view.mouse_x as i32 - tooltip_w as i32 - offset; + } + + // Check if tooltip would go off bottom edge + if tooltip_y + tooltip_h as i32 + margin > height as i32 { + // Position above cursor instead + tooltip_y = view.mouse_y as i32 - tooltip_h as i32 - offset; + } + + // Clamp to screen bounds with margin + tooltip_x = tooltip_x.clamp(margin, (width as i32 - tooltip_w as i32 - margin).max(margin)); + tooltip_y = tooltip_y.clamp(margin, (height as i32 - tooltip_h as i32 - margin).max(margin)); + + let tooltip_x = tooltip_x as usize; + let tooltip_y = tooltip_y as usize; // Draw tooltip background for dy in 0..tooltip_h { @@ -767,31 +873,8 @@ mod window { view: &ViewState, font: &fontdue::Font, ) { - // Build tree structure and compute depths - let mut children: HashMap> = HashMap::new(); - let mut depths: HashMap = HashMap::new(); - let mut roots = Vec::new(); - - for span in spans { - if let Some(parent) = span.parent_id { - children.entry(parent).or_default().push(span); - } else { - roots.push(span.span_id); - depths.insert(span.span_id, 0); - } - } - - // Compute depths for all spans using BFS - let mut queue: Vec = roots.clone(); - while let Some(span_id) = queue.pop() { - let current_depth = depths[&span_id]; - if let Some(child_list) = children.get(&span_id) { - for child in child_list { - depths.insert(child.span_id, current_depth + 1); - queue.push(child.span_id); - } - } - } + // Calculate depths for all spans + let depths = calculate_span_depths(spans); // Group spans by depth level let mut spans_by_depth: HashMap> = HashMap::new(); @@ -859,14 +942,52 @@ mod window { let color = get_color_for_name(span.name); let bar_width = (x2 - x1).max(1) as usize; - fill_rect(framebuffer, width, x1.max(0) as usize, y, bar_width, row_height - 2, color); + // Add inset padding + let inset_x = 2; + let inset_height = 1; + + let actual_width = if bar_width > inset_x * 2 { + bar_width.saturating_sub(inset_x * 2) + } else { bar_width }; + + let actual_height = row_height.saturating_sub(inset_height + 1); // +1 for bottom gap - // Render text if there's enough space, otherwise render to the right - if bar_width > 40 { - draw_text(framebuffer, width, x1.max(0) as usize + 4, y + 4, span.name, font, 14.0, TEXT_COLOR); - } else if x2 >= 0 && x2 < width as i32 { - // Draw text to the right of the bar - draw_text(framebuffer, width, x2.max(0) as usize + 4, y + 4, span.name, font, 14.0, TEXT_COLOR); + if actual_width > 0 && actual_height > 0 { + fill_rect( + framebuffer, + width, + (x1.max(0) as usize).saturating_add(inset_x), + y.saturating_add(inset_height), + actual_width, + actual_height, + color + ); + + // Render text if there's enough space + if actual_width > 40 { + draw_text( + framebuffer, + width, + (x1.max(0) as usize).saturating_add(inset_x + 4), + y.saturating_add(inset_height + 4), + span.name, + font, + 14.0, + TEXT_COLOR + ); + } else if x2 >= 0 && x2 < width as i32 { + // Draw text to the right of the bar + draw_text( + framebuffer, + width, + x2.max(0) as usize + 4, + y + 4, + span.name, + font, + 14.0, + TEXT_COLOR + ); + } } } } @@ -924,30 +1045,41 @@ mod window { // Only render if visible if x2 > 0 && x1 < width as i32 { let color = get_color_for_name(span.name); + let bar_width = (x2 - x1).max(1) as usize; - fill_rect( - framebuffer, - width, - x1.max(0) as usize, - y, - (x2 - x1).max(1) as usize, - row_height - 4, - color - ); + // Add inset padding: 2px horizontal, 1px vertical + let inset_x = 2; + let inset_y = 1; - // Render text if there's enough space - let text_width = (x2 - x1) as usize; - if text_width > 60 { - draw_text( + let actual_width = if bar_width > inset_x * 2 { + bar_width.saturating_sub(inset_x * 2) + } else { bar_width }; + let actual_height = row_height.saturating_sub(inset_y * 2 + 2); // Additional gap + + if actual_width > 0 && actual_height > 0 { + fill_rect( framebuffer, width, - x1.max(0) as usize + 4, - y + row_height / 2 - 6, - span.name, - font, - 12.0, - TEXT_COLOR + (x1.max(0) as usize).saturating_add(inset_x), + y.saturating_add(inset_y), + actual_width, + actual_height, + color ); + + // Render text if there's enough space + if actual_width > 60 { + draw_text( + framebuffer, + width, + (x1.max(0) as usize).saturating_add(inset_x + 4), + y + row_height / 2 - 6, + span.name, + font, + 12.0, + TEXT_COLOR + ); + } } } }