diff --git a/Cargo.lock b/Cargo.lock index 43c6dda..6fcdd8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "autocfg" version = "1.5.0" @@ -72,6 +78,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -94,6 +106,22 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown", + "ttf-parser", +] + [[package]] name = "futures" version = "0.3.31" @@ -206,6 +234,17 @@ dependencies = [ "wasip2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "instant" version = "0.1.13" @@ -546,9 +585,20 @@ name = "teleprof" version = "0.1.0" dependencies = [ "crossbeam-channel", + "fontdue", "minifb", "once_cell", "rand", + "teleprof-macros", +] + +[[package]] +name = "teleprof-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -564,6 +614,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -606,12 +662,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] @@ -723,9 +780,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 8043236..3715493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,7 @@ -[package] -name = "teleprof" -version = "0.1.0" -edition = "2021" +[workspace] +members = ["teleprof", "teleprof-macros"] +resolver = "2" -[dependencies] -minifb = "0.27" +[workspace.dependencies] crossbeam-channel = "0.5" -once_cell = "1.19" - -[dev-dependencies] -rand = "0.8" -minifb = "0.27" \ No newline at end of file +once_cell = "1.19" \ No newline at end of file diff --git a/examples/bouncing_ball.rs b/examples/bouncing_ball.rs index 3b6ca0d..6235234 100644 --- a/examples/bouncing_ball.rs +++ b/examples/bouncing_ball.rs @@ -3,12 +3,12 @@ use std::thread; use std::time::{Duration, Instant}; use std::sync::{Arc, Mutex}; use rand::Rng; +use teleprof::instrument; const WIDTH: usize = 800; const HEIGHT: usize = 600; const BALL_RADIUS: usize = 20; -// Simple ball state struct Ball { x: f32, y: f32, @@ -22,9 +22,9 @@ impl Ball { Self { x: 400.0, y: 300.0, - vx: 200.0, // pixels per second + vx: 200.0, vy: 150.0, - color: 0xFF6464FF, // Red-ish + color: 0xFF6464FF, } } } @@ -32,10 +32,14 @@ impl Ball { fn main() { // Start the telemetry window teleprof::start(); + + // Name the main thread + teleprof::set_thread_name("Main"); println!("Bouncing Ball Demo"); println!("The ball window should appear alongside the profiler"); println!("Press Space in profiler window to pause"); + println!("Mouse wheel to zoom, drag to pan in profiler"); println!("Press Escape in either window to quit"); println!(); @@ -52,49 +56,14 @@ fn main() { let ball = Arc::new(Mutex::new(Ball::new())); let mut framebuffer = vec![0u32; WIDTH * HEIGHT]; - // Target 30 FPS let frame_time = Duration::from_millis(33); let mut frame_count = 0; while window.is_open() && !window.is_key_down(Key::Escape) { let frame_start = Instant::now(); - teleprof::span!("main_frame"); - - // Check if paused - if teleprof::PAUSE.try_lock().is_err() { - println!("Paused!"); - while teleprof::PAUSE.try_lock().is_err() { - thread::sleep(Duration::from_millis(100)); - } - println!("Resumed!"); - } + main_frame(&ball, &mut framebuffer, &mut window, &mut frame_count, frame_time); - // Update physics - let hit_wall = update_physics(&ball, frame_time.as_secs_f32()); - - // If we hit a wall, spawn a thread to pick a new color - if hit_wall { - let ball_clone = Arc::clone(&ball); - thread::spawn(move || { - pick_new_color(ball_clone); - }); - } - - // Render - render(&ball, &mut framebuffer); - - // Update window - window - .update_with_buffer(&framebuffer, WIDTH, HEIGHT) - .expect("Failed to update window"); - - frame_count += 1; - if frame_count % 30 == 0 { - print_status(&ball, frame_count); - } - - // Sleep to maintain 30fps let elapsed = frame_start.elapsed(); if elapsed < frame_time { thread::sleep(frame_time - elapsed); @@ -102,18 +71,58 @@ fn main() { } } -fn update_physics(ball: &Arc>, dt: f32) -> bool { - teleprof::span!("update_physics"); +#[instrument] +fn main_frame( + ball: &Arc>, + framebuffer: &mut [u32], + window: &mut Window, + frame_count: &mut u32, + frame_time: Duration, +) { + // Check if paused + if teleprof::PAUSE.try_lock().is_err() { + println!("Paused!"); + while teleprof::PAUSE.try_lock().is_err() { + thread::sleep(Duration::from_millis(100)); + } + println!("Resumed!"); + } + + // Update physics + let hit_wall = update_physics(ball, frame_time.as_secs_f32()); + // If we hit a wall, spawn a thread to pick a new color + if hit_wall { + let ball_clone = Arc::clone(ball); + thread::spawn(move || { + teleprof::set_thread_name("ColorPicker"); + pick_new_color(ball_clone); + }); + } + + // Render + render(ball, framebuffer); + + // Update window + window + .update_with_buffer(framebuffer, WIDTH, HEIGHT) + .expect("Failed to update window"); + + *frame_count += 1; + if *frame_count % 30 == 0 { + print_status(ball, *frame_count); + } +} + +#[instrument] +fn update_physics(ball: &Arc>, dt: f32) -> bool { let mut ball = ball.lock().unwrap(); - // Update position ball.x += ball.vx * dt; ball.y += ball.vy * dt; let mut hit_wall = false; - // Bounce off walls let radius = BALL_RADIUS as f32; if ball.x - radius < 0.0 || ball.x + radius > WIDTH as f32 { ball.vx = -ball.vx; @@ -127,16 +136,13 @@ fn update_physics(ball: &Arc>, dt: f32) -> bool { hit_wall = true; } - // Simulate some physics computation thread::sleep(Duration::from_millis(5)); hit_wall } +#[instrument] fn pick_new_color(ball: Arc>) { - teleprof::span!("pick_new_color"); - - // Simulate some "expensive" color selection thread::sleep(Duration::from_millis(10)); let mut rng = rand::thread_rng(); @@ -152,50 +158,48 @@ fn pick_new_color(ball: Arc>) { println!(" → New color selected: RGB({}, {}, {})", r, g, b); } +#[instrument] fn render(ball: &Arc>, framebuffer: &mut [u32]) { - teleprof::span!("render"); + clear_background(framebuffer); + draw_ball(ball, framebuffer); + submit_frame(); +} + +#[instrument] +fn clear_background(framebuffer: &mut [u32]) { + framebuffer.fill(0x2A2A2AFF); +} + +#[instrument] +fn draw_ball(ball: &Arc>, framebuffer: &mut [u32]) { + let ball = ball.lock().unwrap(); - { - teleprof::span!("clear_background"); - // Clear to dark gray - framebuffer.fill(0x2A2A2AFF); - } + let cx = ball.x as i32; + let cy = ball.y as i32; + let radius = BALL_RADIUS as i32; - { - teleprof::span!("draw_ball"); - let ball = ball.lock().unwrap(); - - // Draw ball as a filled circle - let cx = ball.x as i32; - let cy = ball.y as i32; - let radius = BALL_RADIUS as i32; - - for dy in -radius..=radius { - for dx in -radius..=radius { - // Check if point is inside circle - if dx * dx + dy * dy <= radius * radius { - let x = cx + dx; - let y = cy + dy; - - if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 { - let idx = y as usize * WIDTH + x as usize; - framebuffer[idx] = ball.color; - } + for dy in -radius..=radius { + for dx in -radius..=radius { + if dx * dx + dy * dy <= radius * radius { + let x = cx + dx; + let y = cy + dy; + + if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 { + let idx = y as usize * WIDTH + x as usize; + framebuffer[idx] = ball.color; } } } } - - { - teleprof::span!("submit_frame"); - // Simulate GPU submission - thread::sleep(Duration::from_millis(2)); - } } +#[instrument] +fn submit_frame() { + thread::sleep(Duration::from_millis(2)); +} + +#[instrument] fn print_status(ball: &Arc>, frame: u32) { - teleprof::span!("print_status"); - let ball = ball.lock().unwrap(); println!( "Frame {}: Ball at ({:.1}, {:.1})", diff --git a/examples/demo.rs b/examples/demo.rs index 79f86ec..a5e8d58 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -1,5 +1,6 @@ use std::thread; use std::time::Duration; +use teleprof::instrument; fn main() { // Start the telemetry window @@ -7,7 +8,9 @@ fn main() { println!("Teleprof demo running..."); println!("Press Space in the profiler window to pause/unpause"); + println!("Mouse wheel to zoom, drag to pan"); println!("Press Escape in the profiler window to quit"); + println!(); // Simulate some work for i in 0..1000 { @@ -26,9 +29,8 @@ fn main() { } } +#[instrument] fn frame_work(frame: u32) { - teleprof::span!("frame_work"); - physics_update(); render(); @@ -37,9 +39,8 @@ fn frame_work(frame: u32) { } } +#[instrument] fn physics_update() { - teleprof::span!("physics_update"); - // Spawn some worker threads let handles: Vec<_> = (0..3).map(|i| { thread::spawn(move || { @@ -52,32 +53,30 @@ fn physics_update() { } } +#[instrument] fn physics_worker(id: u32) { - teleprof::span!("physics_worker"); - // Simulate work let work_ms = 5 + (id * 2); thread::sleep(Duration::from_millis(work_ms as u64)); } +#[instrument] fn render() { - teleprof::span!("render"); - build_command_buffer(); submit_to_gpu(); } +#[instrument] fn build_command_buffer() { - teleprof::span!("build_command_buffer"); thread::sleep(Duration::from_millis(3)); } +#[instrument] fn submit_to_gpu() { - teleprof::span!("submit_to_gpu"); thread::sleep(Duration::from_millis(2)); } +#[instrument] fn occasional_task() { - teleprof::span!("occasional_task"); thread::sleep(Duration::from_millis(10)); -} +} \ No newline at end of file diff --git a/fonts/DejaVuSansMono.ttf b/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..8b7bb2a Binary files /dev/null and b/fonts/DejaVuSansMono.ttf differ diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 75a881b..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crossbeam_channel::{unbounded, Receiver, Sender}; -use once_cell::sync::Lazy; -use std::cell::Cell; -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -// ============================================================================ -// Public API -// ============================================================================ - -/// Global PAUSE lock - acquire this to pause the application -pub static PAUSE: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(()))); - -/// Start the telemetry window in a separate thread -pub fn start() { - std::thread::spawn(|| { - if let Err(e) = window::run() { - eprintln!("Teleprof window error: {}", e); - } - }); -} - -/// Create a profiling span - use via the `span!` macro -pub struct SpanGuard { - span_id: u64, -} - -impl SpanGuard { - pub fn new(name: &'static str) -> Self { - let span_id = next_span_id(); - let thread_id = std::thread::current().id(); - let parent_id = PARENT_SPAN.with(|p| p.get()); - - // Set ourselves as the current parent for nested spans - PARENT_SPAN.with(|p| p.set(Some(span_id))); - - EVENT_SENDER.send(Event::SpanStart { - span_id, - parent_id, - thread_id: thread_id_to_u64(thread_id), - name, - timestamp: Instant::now(), - }).ok(); - - Self { span_id } - } -} - -impl Drop for SpanGuard { - fn drop(&mut self) { - EVENT_SENDER.send(Event::SpanEnd { - span_id: self.span_id, - timestamp: Instant::now(), - }).ok(); - - // Pop back to parent - PARENT_SPAN.with(|p| { - // Find parent by looking at active spans (simplified - just clear for now) - p.set(None); - }); - } -} - -/// Macro for creating a span -#[macro_export] -macro_rules! span { - ($name:expr) => { - let _span_guard = $crate::SpanGuard::new($name); - }; -} - -// ============================================================================ -// Internal types and state -// ============================================================================ - -#[derive(Debug, Clone)] -pub(crate) enum Event { - SpanStart { - span_id: u64, - parent_id: Option, - thread_id: u64, - name: &'static str, - timestamp: Instant, - }, - SpanEnd { - span_id: u64, - timestamp: Instant, - }, -} - -thread_local! { - static PARENT_SPAN: Cell> = Cell::new(None); -} - -static EVENT_SENDER: Lazy> = Lazy::new(|| { - let (tx, rx) = unbounded(); - *EVENT_RECEIVER.lock().unwrap() = Some(rx); - tx -}); - -static EVENT_RECEIVER: Lazy>>>> = - Lazy::new(|| Arc::new(Mutex::new(None))); - -static SPAN_ID_COUNTER: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(0))); - -fn next_span_id() -> u64 { - let mut counter = SPAN_ID_COUNTER.lock().unwrap(); - let id = *counter; - *counter += 1; - id -} - -fn thread_id_to_u64(id: std::thread::ThreadId) -> u64 { - // Hack: ThreadId doesn't expose inner value, so we format and parse - let s = format!("{:?}", id); - s.trim_start_matches("ThreadId(") - .trim_end_matches(")") - .parse() - .unwrap_or(0) -} - -// ============================================================================ -// Window rendering -// ============================================================================ - -mod window { - use super::*; - use minifb::{Key, Window, WindowOptions}; - use std::collections::HashMap; - - const INITIAL_WIDTH: usize = 1280; - const INITIAL_HEIGHT: usize = 720; - const MAX_EVENTS: usize = 1_000_000; // ~16MB at 16 bytes per event - - // Monokai palette - const COLORS: [u32; 8] = [ - 0xF92672, // Pink - 0xA6E22E, // Green - 0xFD971F, // Orange - 0x66D9EF, // Cyan - 0xAE81FF, // Purple - 0xE6DB74, // Yellow - 0xF8F8F2, // White - 0x75715E, // Gray - ]; - - const BG_COLOR: u32 = 0x272822; - const GRID_COLOR: u32 = 0x3E3D32; - - struct CompletedSpan { - span_id: u64, - parent_id: Option, - thread_id: u64, - name: &'static str, - start: Instant, - end: Instant, - } - - struct RingBuffer { - spans: Vec, - head: usize, - pending_starts: HashMap, u64, &'static str, Instant)>, - } - - impl RingBuffer { - fn new() -> Self { - Self { - spans: Vec::with_capacity(MAX_EVENTS), - head: 0, - pending_starts: HashMap::new(), - } - } - - fn push_event(&mut self, event: Event) { - match event { - Event::SpanStart { span_id, parent_id, thread_id, name, timestamp } => { - self.pending_starts.insert(span_id, (span_id, parent_id, thread_id, name, timestamp)); - } - Event::SpanEnd { span_id, timestamp } => { - if let Some((span_id, parent_id, thread_id, name, start)) = self.pending_starts.remove(&span_id) { - let span = CompletedSpan { - span_id, - parent_id, - thread_id, - name, - start, - end: timestamp, - }; - - if self.spans.len() < MAX_EVENTS { - self.spans.push(span); - } else { - self.spans[self.head] = span; - self.head = (self.head + 1) % MAX_EVENTS; - } - } - } - } - } - - fn iter(&self) -> Box + '_> { - if self.spans.len() < MAX_EVENTS { - Box::new(self.spans.iter()) - } else { - // Return items in chronological order from ringbuffer - Box::new(self.spans[self.head..].iter().chain(self.spans[..self.head].iter())) - } - } - } - - struct ViewState { - time_offset: f64, // seconds - time_scale: f64, // pixels per second - paused: bool, - pause_guard: Option>, - } - - pub fn run() -> Result<(), Box> { - let mut window = Window::new( - "Teleprof", - INITIAL_WIDTH, - INITIAL_HEIGHT, - WindowOptions::default(), - )?; - - window.set_target_fps(60); - - let receiver = EVENT_RECEIVER.lock().unwrap().take() - .ok_or("Event receiver not initialized")?; - - let mut buffer = RingBuffer::new(); - let mut view = ViewState { - time_offset: 0.0, - time_scale: 100.0, // 100 pixels per second - paused: false, - pause_guard: None, - }; - - let mut framebuffer = vec![BG_COLOR; INITIAL_WIDTH * INITIAL_HEIGHT]; - - while window.is_open() && !window.is_key_down(Key::Escape) { - // Drain events from channel - while let Ok(event) = receiver.try_recv() { - buffer.push_event(event); - } - - // Handle input - if window.is_key_pressed(Key::Space, minifb::KeyRepeat::No) { - view.paused = !view.paused; - if view.paused { - view.pause_guard = PAUSE.try_lock().ok(); - } else { - view.pause_guard = None; - } - } - - // Get current window size - let (width, height) = window.get_size(); - if framebuffer.len() != width * height { - framebuffer.resize(width * height, BG_COLOR); - } - - // Clear framebuffer - framebuffer.fill(BG_COLOR); - - // Render - render_frame(&mut framebuffer, width, height, &buffer, &view); - - window.update_with_buffer(&framebuffer, width, height)?; - } - - Ok(()) - } - - fn render_frame( - framebuffer: &mut [u32], - width: usize, - height: usize, - buffer: &RingBuffer, - view: &ViewState, - ) { - let icicle_height = height / 2; - let timeline_height = height - icicle_height; - - // Find time range - let spans: Vec<_> = buffer.iter().collect(); - if spans.is_empty() { - return; - } - - let earliest = spans.iter().map(|s| s.start).min().unwrap(); - let latest = spans.iter().map(|s| s.end).max().unwrap(); - let duration = (latest - earliest).as_secs_f64(); - - // Draw icicle graph (top half) - render_icicle(framebuffer, width, icicle_height, &spans, earliest, duration, view); - - // Draw timeline (bottom half) - render_timeline( - framebuffer, - width, - height, - icicle_height, - timeline_height, - &spans, - earliest, - duration, - view, - ); - } - - fn render_icicle( - framebuffer: &mut [u32], - width: usize, - height: usize, - spans: &[&CompletedSpan], - earliest: Instant, - duration: f64, - view: &ViewState, - ) { - // Build tree structure - let mut roots = Vec::new(); - let mut children: HashMap> = HashMap::new(); - - for span in spans { - if let Some(parent) = span.parent_id { - children.entry(parent).or_default().push(span); - } else { - roots.push(*span); - } - } - - // Render each root and its children recursively - let y_start = 0; - let row_height = 20; - - for root in roots { - render_icicle_span( - framebuffer, - width, - height, - root, - &children, - earliest, - duration, - y_start, - row_height, - ); - } - } - - fn render_icicle_span( - framebuffer: &mut [u32], - width: usize, - height: usize, - span: &CompletedSpan, - children: &HashMap>, - earliest: Instant, - duration: f64, - y: usize, - row_height: usize, - ) { - if y + row_height > height { - return; - } - - let start_time = (span.start - earliest).as_secs_f64(); - let end_time = (span.end - earliest).as_secs_f64(); - - let x1 = ((start_time / duration) * width as f64) as usize; - let x2 = ((end_time / duration) * width as f64) as usize; - - let color = get_color_for_name(span.name); - - fill_rect(framebuffer, width, x1, y, x2 - x1, row_height, color); - - // Render children - if let Some(child_spans) = children.get(&span.span_id) { - let child_y = y + row_height; - for child in child_spans { - render_icicle_span( - framebuffer, - width, - height, - child, - children, - earliest, - duration, - child_y, - row_height, - ); - } - } - } - - fn render_timeline( - framebuffer: &mut [u32], - width: usize, - _height: usize, - y_offset: usize, - timeline_height: usize, - spans: &[&CompletedSpan], - earliest: Instant, - duration: f64, - _view: &ViewState, - ) { - // Group by thread - let mut threads: HashMap> = HashMap::new(); - for span in spans { - threads.entry(span.thread_id).or_default().push(*span); - } - - let thread_ids: Vec<_> = threads.keys().copied().collect(); - let num_threads = thread_ids.len(); - if num_threads == 0 { - return; - } - - let row_height = timeline_height / num_threads.max(1); - - for (i, thread_id) in thread_ids.iter().enumerate() { - let y = y_offset + i * row_height; - let thread_spans = &threads[thread_id]; - - for span in thread_spans { - let start_time = (span.start - earliest).as_secs_f64(); - let end_time = (span.end - earliest).as_secs_f64(); - - let x1 = ((start_time / duration) * width as f64) as usize; - let x2 = ((end_time / duration) * width as f64).max(x1 as f64 + 1.0) as usize; - - let color = get_color_for_name(span.name); - - fill_rect(framebuffer, width, x1, y, x2 - x1, row_height - 2, color); - } - } - } - - fn fill_rect(framebuffer: &mut [u32], width: usize, x: usize, y: usize, w: usize, h: usize, color: u32) { - for dy in 0..h { - let row = y + dy; - for dx in 0..w { - let col = x + dx; - if col < width { - let idx = row * width + col; - if idx < framebuffer.len() { - framebuffer[idx] = color; - } - } - } - } - } - - fn get_color_for_name(name: &str) -> u32 { - let hash = name.bytes().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); - COLORS[(hash as usize) % COLORS.len()] - } -} \ No newline at end of file diff --git a/teleprof-macros/Cargo.toml b/teleprof-macros/Cargo.toml new file mode 100644 index 0000000..2ef149b --- /dev/null +++ b/teleprof-macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "teleprof-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full"] } +quote = "1.0" +proc-macro2 = "1.0" \ No newline at end of file diff --git a/teleprof-macros/src/lib.rs b/teleprof-macros/src/lib.rs new file mode 100644 index 0000000..766cff7 --- /dev/null +++ b/teleprof-macros/src/lib.rs @@ -0,0 +1,58 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, ItemFn, Lit}; + +#[proc_macro_attribute] +pub fn instrument(args: TokenStream, input: TokenStream) -> TokenStream { + let input_fn = parse_macro_input!(input as ItemFn); + + // Parse custom name from attributes + let mut custom_name: Option = None; + + let args_parser = syn::meta::parser(|meta| { + if meta.path.is_ident("name") { + let value: Lit = meta.value()?.parse()?; + if let Lit::Str(lit_str) = value { + custom_name = Some(lit_str.value()); + } + Ok(()) + } else { + Err(meta.error("unsupported attribute")) + } + }); + + let _ = parse_macro_input!(args with args_parser); + + let fn_name = &input_fn.sig.ident; + let span_name = custom_name.unwrap_or_else(|| fn_name.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; + + // Check if function is async + let is_async = fn_sig.asyncness.is_some(); + + let instrumented = if is_async { + // For async functions, we need to instrument the future + quote! { + #(#fn_attrs)* + #fn_vis #fn_sig { + let _guard = ::teleprof::SpanGuard::new(#span_name); + async move #fn_block + } + } + } else { + // For sync functions, straightforward instrumentation + quote! { + #(#fn_attrs)* + #fn_vis #fn_sig { + let _guard = ::teleprof::SpanGuard::new(#span_name); + #fn_block + } + } + }; + + TokenStream::from(instrumented) +} \ No newline at end of file diff --git a/teleprof/Cargo.toml b/teleprof/Cargo.toml new file mode 100644 index 0000000..a45fc92 --- /dev/null +++ b/teleprof/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "teleprof" +version = "0.1.0" +edition = "2021" + +[dependencies] +teleprof-macros = { path = "../teleprof-macros" } +crossbeam-channel = { workspace = true } +once_cell = { workspace = true } +minifb = "0.27" +fontdue = "0.9" + +[dev-dependencies] +rand = "0.8" + +[[example]] +name = "demo" +path = "../examples/demo.rs" + +[[example]] +name = "bouncing_ball" +path = "../examples/bouncing_ball.rs" \ No newline at end of file diff --git a/teleprof/src/lib.rs b/teleprof/src/lib.rs new file mode 100644 index 0000000..50d0587 --- /dev/null +++ b/teleprof/src/lib.rs @@ -0,0 +1,691 @@ +use crossbeam_channel::{unbounded, Receiver, Sender}; +use once_cell::sync::Lazy; +use std::cell::Cell; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +// Re-export the macro +pub use teleprof_macros::instrument; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Global PAUSE lock - acquire this to pause the application +pub static PAUSE: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(()))); + +/// Start the telemetry window in a separate thread +pub fn start() { + std::thread::spawn(|| { + if let Err(e) = window::run() { + eprintln!("Teleprof window error: {}", e); + } + }); +} + +/// Set the name for the current thread (will appear in timeline view) +pub fn set_thread_name(name: impl Into) { + let thread_id = std::thread::current().id(); + EVENT_SENDER.send(Event::ThreadName { + thread_id: thread_id_to_u64(thread_id), + name: name.into(), + }).ok(); +} + +/// Create a profiling span +pub struct SpanGuard { + span_id: u64, +} + +impl SpanGuard { + pub fn new(name: &'static str) -> Self { + let span_id = next_span_id(); + let thread_id = std::thread::current().id(); + let parent_id = PARENT_SPAN.with(|p| p.get()); + + PARENT_SPAN.with(|p| p.set(Some(span_id))); + + EVENT_SENDER.send(Event::SpanStart { + span_id, + parent_id, + thread_id: thread_id_to_u64(thread_id), + name, + timestamp: Instant::now(), + }).ok(); + + Self { span_id } + } +} + +impl Drop for SpanGuard { + fn drop(&mut self) { + EVENT_SENDER.send(Event::SpanEnd { + span_id: self.span_id, + timestamp: Instant::now(), + }).ok(); + + // Restore parent + PARENT_SPAN.with(|p| p.set(None)); + } +} + +/// Macro for creating a span manually +#[macro_export] +macro_rules! span { + ($name:expr) => { + let _span_guard = $crate::SpanGuard::new($name); + }; +} + +// ============================================================================ +// Internal types and state +// ============================================================================ + +#[derive(Debug, Clone)] +pub(crate) enum Event { + SpanStart { + span_id: u64, + parent_id: Option, + thread_id: u64, + name: &'static str, + timestamp: Instant, + }, + SpanEnd { + span_id: u64, + timestamp: Instant, + }, + ThreadName { + thread_id: u64, + name: String, + }, +} + +thread_local! { + static PARENT_SPAN: Cell> = Cell::new(None); +} + +static EVENT_SENDER: Lazy> = Lazy::new(|| { + let (tx, rx) = unbounded(); + *EVENT_RECEIVER.lock().unwrap() = Some(rx); + tx +}); + +static EVENT_RECEIVER: Lazy>>>> = + Lazy::new(|| Arc::new(Mutex::new(None))); + +static SPAN_ID_COUNTER: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(0))); + +fn next_span_id() -> u64 { + let mut counter = SPAN_ID_COUNTER.lock().unwrap(); + let id = *counter; + *counter += 1; + id +} + +fn thread_id_to_u64(id: std::thread::ThreadId) -> u64 { + let s = format!("{:?}", id); + s.trim_start_matches("ThreadId(") + .trim_end_matches(")") + .parse() + .unwrap_or(0) +} + +// ============================================================================ +// Window rendering +// ============================================================================ + +mod window { + use super::*; + use minifb::{Key, MouseButton, MouseMode, Window, WindowOptions}; + use std::collections::HashMap; + + const INITIAL_WIDTH: usize = 1280; + const INITIAL_HEIGHT: usize = 720; + const MAX_EVENTS: usize = 1_000_000; + + // Monokai palette + const COLORS: [u32; 8] = [ + 0xF92672, // Pink + 0xA6E22E, // Green + 0xFD971F, // Orange + 0x66D9EF, // Cyan + 0xAE81FF, // Purple + 0xE6DB74, // Yellow + 0xF8F8F2, // White + 0x75715E, // Gray + ]; + + const BG_COLOR: u32 = 0x272822; + const TEXT_COLOR: u32 = 0xF8F8F2; + + struct CompletedSpan { + span_id: u64, + parent_id: Option, + thread_id: u64, + name: &'static str, + start: Instant, + end: Instant, + } + + struct RingBuffer { + spans: Vec, + head: usize, + pending_starts: HashMap, u64, &'static str, Instant)>, + thread_names: HashMap, + } + + impl RingBuffer { + fn new() -> Self { + Self { + spans: Vec::with_capacity(MAX_EVENTS), + head: 0, + pending_starts: HashMap::new(), + thread_names: HashMap::new(), + } + } + + fn push_event(&mut self, event: Event) { + match event { + Event::SpanStart { span_id, parent_id, thread_id, name, timestamp } => { + self.pending_starts.insert(span_id, (span_id, parent_id, thread_id, name, timestamp)); + } + Event::SpanEnd { span_id, timestamp } => { + if let Some((span_id, parent_id, thread_id, name, start)) = self.pending_starts.remove(&span_id) { + let span = CompletedSpan { + span_id, + parent_id, + thread_id, + name, + start, + end: timestamp, + }; + + if self.spans.len() < MAX_EVENTS { + self.spans.push(span); + } else { + self.spans[self.head] = span; + self.head = (self.head + 1) % MAX_EVENTS; + } + } + } + Event::ThreadName { thread_id, name } => { + self.thread_names.insert(thread_id, name); + } + } + } + + fn iter(&self) -> Box + '_> { + if self.spans.len() < MAX_EVENTS { + Box::new(self.spans.iter()) + } else { + Box::new(self.spans[self.head..].iter().chain(self.spans[..self.head].iter())) + } + } + } + + struct ViewState { + // Pan/zoom (synchronized between views) + icicle_time_offset: f64, + icicle_time_scale: f64, + timeline_time_offset: f64, + timeline_time_scale: f64, + + // Mouse state + mouse_down: bool, + last_mouse_x: f32, + last_mouse_y: f32, + mouse_visible: bool, + mouse_x: f32, + mouse_y: f32, + + // Pause state + paused: bool, + pause_guard: Option>, + } + + impl ViewState { + fn new() -> Self { + Self { + icicle_time_offset: 0.0, + icicle_time_scale: 100.0, + timeline_time_offset: 0.0, + timeline_time_scale: 100.0, + mouse_down: false, + last_mouse_x: 0.0, + last_mouse_y: 0.0, + mouse_visible: false, + mouse_x: 0.0, + mouse_y: 0.0, + paused: false, + pause_guard: None, + } + } + } + + pub fn run() -> Result<(), Box> { + let mut window = Window::new( + "Teleprof", + INITIAL_WIDTH, + INITIAL_HEIGHT, + WindowOptions::default(), + )?; + + window.set_target_fps(60); + + let receiver = EVENT_RECEIVER.lock().unwrap().take() + .ok_or("Event receiver not initialized")?; + + let mut buffer = RingBuffer::new(); + let mut view = ViewState::new(); + let mut framebuffer = vec![BG_COLOR; INITIAL_WIDTH * INITIAL_HEIGHT]; + + // Load font + let font_data = include_bytes!("../../fonts/DejaVuSansMono.ttf"); + let font = fontdue::Font::from_bytes(font_data as &[u8], fontdue::FontSettings::default())?; + + while window.is_open() && !window.is_key_down(Key::Escape) { + // Drain events from channel + while let Ok(event) = receiver.try_recv() { + buffer.push_event(event); + } + + let (width, height) = window.get_size(); + + // Handle input + handle_input(&mut window, &mut view, width, height); + + // Resize framebuffer if needed + if framebuffer.len() != width * height { + framebuffer.resize(width * height, BG_COLOR); + } + + framebuffer.fill(BG_COLOR); + + // Render + render_frame(&mut framebuffer, width, height, &buffer, &view, &font); + + window.update_with_buffer(&framebuffer, width, height)?; + } + + Ok(()) + } + + fn handle_input(window: &mut Window, view: &mut ViewState, _width: usize, height: usize) { + // Pause/unpause + if window.is_key_pressed(Key::Space, minifb::KeyRepeat::No) { + view.paused = !view.paused; + if view.paused { + view.pause_guard = PAUSE.try_lock().ok(); + } else { + view.pause_guard = None; + } + } + + // Get mouse position + if let Some((mx, my)) = window.get_mouse_pos(MouseMode::Clamp) { + view.mouse_visible = true; + view.mouse_x = mx; + view.mouse_y = my; + + let icicle_height = height / 2; + let _is_icicle = my < icicle_height as f32; + + // Handle mouse wheel (zoom) + if let Some((_, scroll_y)) = window.get_scroll_wheel() { + if scroll_y != 0.0 { + let zoom_factor = if scroll_y > 0.0 { 1.2 } else { 1.0 / 1.2 }; + + // Synchronize both views - zoom changes scale for both + let old_scale = view.icicle_time_scale; + let new_scale = old_scale * zoom_factor; + + // Calculate mouse time in world space (same for both views since synchronized) + let mouse_time = view.icicle_time_offset + (mx as f64 / old_scale); + + // Update scale and adjust offset to keep mouse position stable + view.icicle_time_scale = new_scale; + view.timeline_time_scale = new_scale; + + let new_offset = mouse_time - (mx as f64 / new_scale); + view.icicle_time_offset = new_offset; + view.timeline_time_offset = new_offset; + } + } + + // Handle mouse drag (pan) + let mouse_down = window.get_mouse_down(MouseButton::Left); + + if mouse_down && view.mouse_down { + let dx = mx - view.last_mouse_x; + + // Pan both views together (synchronized) + let delta = dx as f64 / view.icicle_time_scale; + view.icicle_time_offset -= delta; + view.timeline_time_offset -= delta; + } + + view.mouse_down = mouse_down; + view.last_mouse_x = mx; + view.last_mouse_y = my; + } else { + view.mouse_visible = false; + } + } + + fn render_frame( + framebuffer: &mut [u32], + width: usize, + height: usize, + buffer: &RingBuffer, + view: &ViewState, + font: &fontdue::Font, + ) { + let icicle_height = height / 2; + let timeline_height = height - icicle_height; + + let spans: Vec<_> = buffer.iter().collect(); + if spans.is_empty() { + return; + } + + let earliest = spans.iter().map(|s| s.start).min().unwrap(); + let _latest = spans.iter().map(|s| s.end).max().unwrap(); + + // Render icicle graph (top half) + render_icicle(framebuffer, width, icicle_height, &spans, earliest, view, font); + + // Draw separator line + for x in 0..width { + framebuffer[icicle_height * width + x] = 0x505050; + } + + // Render timeline (bottom half) + render_timeline( + framebuffer, + width, + height, + icicle_height, + timeline_height, + buffer, + earliest, + view, + font, + ); + + // Draw cursor + if view.mouse_visible { + let cursor_x = view.mouse_x as usize; + + // Draw vertical line + for y in 0..height { + if cursor_x < width { + let idx = y * width + cursor_x; + if idx < framebuffer.len() { + // Make cursor semi-transparent by blending + let bg = framebuffer[idx]; + let bg_r = ((bg >> 16) & 0xFF) as u32; + let bg_g = ((bg >> 8) & 0xFF) as u32; + let bg_b = (bg & 0xFF) as u32; + + let alpha = 0.5; + let r = (bg_r as f32 * (1.0 - alpha) + 255.0 * alpha) as u32; + let g = (bg_g as f32 * (1.0 - alpha) + 255.0 * alpha) as u32; + let b = (bg_b as f32 * (1.0 - alpha) + 255.0 * alpha) as u32; + + framebuffer[idx] = (r << 16) | (g << 8) | b; + } + } + } + } + } + + fn render_icicle( + framebuffer: &mut [u32], + width: usize, + height: usize, + spans: &[&CompletedSpan], + earliest: Instant, + view: &ViewState, + font: &fontdue::Font, + ) { + // Build tree structure + let mut roots = Vec::new(); + let mut children: HashMap> = HashMap::new(); + + for span in spans { + if let Some(parent) = span.parent_id { + children.entry(parent).or_default().push(span); + } else { + roots.push(*span); + } + } + + // Render each root and its children recursively + let row_height = 24; + let mut y_offset = 0; + + for root in roots { + y_offset = render_icicle_span( + framebuffer, + width, + height, + root, + &children, + earliest, + y_offset, + row_height, + view, + font, + ); + } + } + + fn render_icicle_span( + framebuffer: &mut [u32], + width: usize, + height: usize, + span: &CompletedSpan, + children: &HashMap>, + earliest: Instant, + y: usize, + row_height: usize, + view: &ViewState, + font: &fontdue::Font, + ) -> usize { + if y + row_height > height { + return y; + } + + let start_time = (span.start - earliest).as_secs_f64(); + let end_time = (span.end - earliest).as_secs_f64(); + + // Apply zoom and pan + let x1 = ((start_time - view.icicle_time_offset) * view.icicle_time_scale) as i32; + let x2 = ((end_time - view.icicle_time_offset) * view.icicle_time_scale) as i32; + + // Only render if visible + if x2 > 0 && x1 < width as i32 { + let color = get_color_for_name(span.name); + + fill_rect(framebuffer, width, x1.max(0) as usize, y, (x2 - x1).max(1) as usize, row_height - 2, color); + + // Render text if there's enough space + let text_width = (x2 - x1) as usize; + if text_width > 40 { + draw_text(framebuffer, width, x1.max(0) as usize + 4, y + 4, span.name, font, 14.0, TEXT_COLOR); + } + } + + // Render children + let mut next_y = y + row_height; + if let Some(child_spans) = children.get(&span.span_id) { + for child in child_spans { + next_y = render_icicle_span( + framebuffer, + width, + height, + child, + children, + earliest, + next_y, + row_height, + view, + font, + ); + } + } + + next_y + } + + fn render_timeline( + framebuffer: &mut [u32], + width: usize, + _height: usize, + y_offset: usize, + timeline_height: usize, + buffer: &RingBuffer, + earliest: Instant, + view: &ViewState, + font: &fontdue::Font, + ) { + // Collect all spans + let spans: Vec<_> = buffer.iter().collect(); + + // Group by thread and sort for stable ordering + 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 { + return; + } + + let row_height = (timeline_height / num_threads.max(1)).max(20); + + for (i, thread_id) in thread_ids.iter().enumerate() { + let y = y_offset + i * row_height; + let thread_spans = &threads[thread_id]; + + // Draw thread label with name if available + let label = if let Some(name) = buffer.thread_names.get(thread_id) { + name.clone() + } else { + format!("Thread {}", thread_id) + }; + draw_text(framebuffer, width, 4, y + 4, &label, font, 12.0, TEXT_COLOR); + + for span in thread_spans { + let start_time = (span.start - earliest).as_secs_f64(); + let end_time = (span.end - earliest).as_secs_f64(); + + // Apply zoom and pan + let x1 = ((start_time - view.timeline_time_offset) * view.timeline_time_scale) as i32; + let x2 = ((end_time - view.timeline_time_offset) * view.timeline_time_scale) as i32; + + // Only render if visible + if x2 > 0 && x1 < width as i32 { + let color = get_color_for_name(span.name); + + fill_rect( + framebuffer, + width, + x1.max(0) as usize, + y, + (x2 - x1).max(1) as usize, + row_height - 4, + color + ); + + // Render text if there's enough space + let text_width = (x2 - x1) as usize; + if text_width > 60 { + draw_text( + framebuffer, + width, + x1.max(0) as usize + 4, + y + row_height / 2 - 6, + span.name, + font, + 12.0, + TEXT_COLOR + ); + } + } + } + } + } + + fn fill_rect(framebuffer: &mut [u32], width: usize, x: usize, y: usize, w: usize, h: usize, color: u32) { + for dy in 0..h { + let row = y + dy; + for dx in 0..w { + let col = x + dx; + if col < width { + let idx = row * width + col; + if idx < framebuffer.len() { + framebuffer[idx] = color; + } + } + } + } + } + + fn draw_text( + framebuffer: &mut [u32], + width: usize, + x: usize, + y: usize, + text: &str, + font: &fontdue::Font, + size: f32, + color: u32, + ) { + let mut cursor_x = x as f32; + + for ch in text.chars() { + let (metrics, bitmap) = font.rasterize(ch, size); + + for (i, &alpha) in bitmap.iter().enumerate() { + if alpha > 0 { + let px = cursor_x as usize + (i % metrics.width); + let py = y + (i / metrics.width) + (size as usize - metrics.height); + + if px < width { + let idx = py * width + px; + if idx < framebuffer.len() { + // Alpha blend + let alpha_f = alpha as f32 / 255.0; + let bg = framebuffer[idx]; + let bg_r = ((bg >> 16) & 0xFF) as f32; + let bg_g = ((bg >> 8) & 0xFF) as f32; + let bg_b = (bg & 0xFF) as f32; + + let fg_r = ((color >> 16) & 0xFF) as f32; + let fg_g = ((color >> 8) & 0xFF) as f32; + let fg_b = (color & 0xFF) as f32; + + let r = (bg_r * (1.0 - alpha_f) + fg_r * alpha_f) as u32; + let g = (bg_g * (1.0 - alpha_f) + fg_g * alpha_f) as u32; + let b = (bg_b * (1.0 - alpha_f) + fg_b * alpha_f) as u32; + + framebuffer[idx] = (r << 16) | (g << 8) | b; + } + } + } + } + + cursor_x += metrics.advance_width; + } + } + + fn get_color_for_name(name: &str) -> u32 { + let hash = name.bytes().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); + COLORS[(hash as usize) % COLORS.len()] + } +} \ No newline at end of file