#![allow(missing_docs)]
#![allow(dead_code)]
use std::time::Instant;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeNode {
pub name: String,
pub call_count: u64,
pub total_ns: u64,
pub max_ns: u64,
pub children: Vec<ScopeNode>,
}
struct ArenaNode {
name: &'static str,
parent: Option<usize>,
call_count: u64,
total_ns: u64,
max_ns: u64,
first_child: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrameReport {
pub frame_ns: u64,
pub root: ScopeNode,
}
impl FrameReport {
pub fn to_csv(&self) -> String {
let mut out = String::from("name,call_count,total_ns,max_ns\n");
Self::csv_node(&self.root, &mut out);
out
}
fn csv_node(node: &ScopeNode, out: &mut String) {
out.push_str(&format!(
"{},{},{},{}\n",
node.name, node.call_count, node.total_ns, node.max_ns
));
for child in &node.children {
Self::csv_node(child, out);
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn to_folded_stacks(&self) -> String {
let mut out = String::new();
Self::folded_node(&self.root, &self.root.name, &mut out);
out
}
fn folded_node(node: &ScopeNode, path: &str, out: &mut String) {
if node.children.is_empty() {
out.push_str(&format!("{} {}\n", path, node.total_ns));
} else {
for child in &node.children {
let child_path = format!("{};{}", path, child.name);
Self::folded_node(child, &child_path, out);
}
}
}
}
pub struct ProfilerSession {
nodes: Vec<ArenaNode>,
stack: Vec<usize>,
frame_start: Option<Instant>,
}
impl Default for ProfilerSession {
fn default() -> Self {
Self::new()
}
}
impl ProfilerSession {
pub fn new() -> Self {
Self {
nodes: Vec::new(),
stack: Vec::new(),
frame_start: None,
}
}
pub fn begin_frame(&mut self) {
self.nodes.clear();
self.stack.clear();
self.frame_start = Some(Instant::now());
}
pub fn scope(&mut self, name: &'static str) -> ScopeGuard {
let parent = self.stack.last().copied();
let node_idx = self
.nodes
.iter()
.position(|n| n.name == name && n.parent == parent)
.unwrap_or_else(|| {
let idx = self.nodes.len();
self.nodes.push(ArenaNode {
name,
parent,
call_count: 0,
total_ns: 0,
max_ns: 0,
first_child: None,
});
idx
});
self.stack.push(node_idx);
ScopeGuard {
session: self as *mut ProfilerSession,
node_idx,
start: Instant::now(),
}
}
pub fn end_frame(&mut self) -> FrameReport {
let frame_ns = self
.frame_start
.take()
.map_or(0, |t| t.elapsed().as_nanos() as u64);
let root = self.build_tree(None, "frame");
self.nodes.clear();
self.stack.clear();
FrameReport { frame_ns, root }
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
fn build_tree(&self, parent_idx: Option<usize>, name: &str) -> ScopeNode {
let mut children = Vec::new();
let child_indices: Vec<usize> = self
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.parent == parent_idx)
.map(|(i, _)| i)
.collect();
for idx in child_indices {
let n = &self.nodes[idx];
let child_node = self.build_tree(Some(idx), n.name);
children.push(child_node);
}
if let Some(idx) = parent_idx {
let n = &self.nodes[idx];
ScopeNode {
name: n.name.to_owned(),
call_count: n.call_count,
total_ns: n.total_ns,
max_ns: n.max_ns,
children,
}
} else {
let total_ns: u64 = children.iter().map(|c| c.total_ns).sum();
ScopeNode {
name: name.to_owned(),
call_count: 1,
total_ns,
max_ns: total_ns,
children,
}
}
}
}
pub struct ScopeGuard {
session: *mut ProfilerSession,
node_idx: usize,
start: Instant,
}
impl Drop for ScopeGuard {
fn drop(&mut self) {
let elapsed = self.start.elapsed().as_nanos() as u64;
let session = unsafe { &mut *self.session };
let node = &mut session.nodes[self.node_idx];
node.call_count += 1;
node.total_ns += elapsed;
node.max_ns = node.max_ns.max(elapsed);
session.stack.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one_frame<F>(f: F) -> FrameReport
where
F: FnOnce(&mut ProfilerSession),
{
let mut s = ProfilerSession::new();
s.begin_frame();
f(&mut s);
s.end_frame()
}
#[test]
fn test_nested_scopes() {
let report = one_frame(|s| {
let _a = s.scope("a");
let _b = s.scope("b");
});
assert_eq!(report.root.name, "frame");
assert_eq!(report.root.children.len(), 1);
let a = &report.root.children[0];
assert_eq!(a.name, "a");
assert_eq!(a.children.len(), 1);
let b = &a.children[0];
assert_eq!(b.name, "b");
assert!(b.call_count >= 1);
}
#[test]
fn test_sibling_scopes() {
let report = one_frame(|s| {
{
let _x = s.scope("x");
}
{
let _y = s.scope("y");
}
});
assert_eq!(report.root.children.len(), 2);
let names: Vec<&str> = report
.root
.children
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"x"), "should have scope x");
assert!(names.contains(&"y"), "should have scope y");
for child in &report.root.children {
assert_eq!(child.call_count, 1);
}
}
#[test]
fn test_repeated_scope_accumulates() {
let report = one_frame(|s| {
for _ in 0..2 {
let _z = s.scope("z");
std::thread::sleep(std::time::Duration::from_micros(10));
}
});
assert_eq!(report.root.children.len(), 1);
let z = &report.root.children[0];
assert_eq!(z.name, "z");
assert_eq!(
z.call_count, 2,
"same-named scope at same level should accumulate"
);
assert!(z.total_ns > 0, "total_ns must be > 0");
}
#[test]
fn test_drop_order_stack_empty() {
let mut s = ProfilerSession::new();
s.begin_frame();
{
let _a = s.scope("outer");
{
let _b = s.scope("inner");
} } let _report = s.end_frame();
assert_eq!(s.node_count(), 0, "arena should be cleared after end_frame");
}
#[test]
fn test_folded_stacks_format() {
let report = one_frame(|s| {
let _a = s.scope("scope_a");
let _b = s.scope("scope_b");
});
let folded = report.to_folded_stacks();
assert!(!folded.is_empty(), "folded stacks should not be empty");
for line in folded.lines() {
let mut parts = line.rsplitn(2, ' ');
let ns_str = parts.next().expect("should have ns value");
let path_str = parts.next().expect("should have path");
let _ns: u64 = ns_str
.parse()
.unwrap_or_else(|_| panic!("ns value should be a valid u64, got '{ns_str}'"));
assert!(
path_str.starts_with("frame"),
"path should start with 'frame', got '{path_str}'"
);
assert!(
path_str.contains(';'),
"path should have semicolons, got '{path_str}'"
);
}
}
#[test]
fn test_json_round_trip() {
let report = one_frame(|s| {
let _a = s.scope("alpha");
});
let json = report.to_json();
let decoded: FrameReport =
serde_json::from_str(&json).expect("JSON should deserialise back to FrameReport");
assert_eq!(decoded.root.name, "frame");
assert_eq!(
decoded.root.children.len(),
report.root.children.len(),
"round-tripped report should have same child count"
);
}
#[test]
fn test_begin_frame_clears() {
let mut s = ProfilerSession::new();
s.begin_frame();
let _g = s.scope("scope1");
drop(_g);
let _r = s.end_frame();
s.begin_frame();
assert_eq!(
s.node_count(),
0,
"begin_frame should clear arena from previous frame"
);
}
#[test]
fn test_csv_output() {
let report = one_frame(|s| {
let _a = s.scope("work");
});
let csv = report.to_csv();
let mut lines = csv.lines();
let header = lines.next().expect("CSV should have a header");
assert_eq!(header, "name,call_count,total_ns,max_ns");
let data_lines: Vec<&str> = lines.collect();
assert!(
!data_lines.is_empty(),
"CSV should have at least one data line"
);
for line in &data_lines {
let cols: Vec<&str> = line.split(',').collect();
assert_eq!(
cols.len(),
4,
"each data line should have 4 columns, got {line}"
);
cols[1].parse::<u64>().expect("call_count should be u64");
cols[2].parse::<u64>().expect("total_ns should be u64");
cols[3].parse::<u64>().expect("max_ns should be u64");
}
}
#[test]
fn test_frame_ns_populated() {
let mut s = ProfilerSession::new();
s.begin_frame();
let _g = s.scope("tick");
std::thread::sleep(std::time::Duration::from_micros(10));
drop(_g);
let report = s.end_frame();
assert!(report.frame_ns > 0, "frame_ns should be > 0");
}
}