From 8613d9962935f2f3ab239df049d7857a26316927 Mon Sep 17 00:00:00 2001 From: chairmanpow Date: Sun, 4 Oct 2026 15:59:43 +0100 Subject: [PATCH] perf: Linux per-thread CPU table; main_in and faults print without it --- .../src/capture/probes/live_fps.rs | 66 ++++++++++--------- crates/benilla-app/src/perf/clock.rs | 32 ++++++++- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index fc01d65e..01b37dd1 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -424,8 +424,38 @@ fn drive_live_fps( }) .collect::>() .join(","); - // Per thread, ms per frame; threads sharing a name (a pool) are summed. - let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) { + // The main-thread split and the page-fault count are Linux-capable; only the + // per-thread table is macOS, so they print without it. + let inside = main_split + .inside_ms() + .map(|m| format!(" main_in={m:.2}")) + .unwrap_or_default(); + let now_still = still_inputs_now(); + let noisy: Vec = now_still + .iter() + .zip(probe.still_at_start) + .map(|(n, s)| n.wrapping_sub(s)) + .collect(); + // Page faults per frame: `sys` time without syscalls is the kernel + // zero-filling pages. + let faults = match (probe.faults_at_start, crate::perf::process_faults()) { + (Some((mi0, ma0)), Some((mi1, ma1))) => format!( + " faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{},portals:{},flips:{}]", + (mi1 - mi0) as f64 / v_len as f64, + (ma1 - ma0) as f64 / v_len as f64, + noisy[0], + noisy[1], + noisy[2], + noisy[3], + noisy[4], + noisy[5], + noisy[6], + ), + _ => String::new(), + }; + // Per thread, ms per frame; threads sharing a name (a pool) are summed. The + // table is macOS/Linux; elsewhere it is absent and `main_in`/`faults` stand alone. + let mut threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) { (Some(t0), Some(t1)) => { let start: std::collections::HashMap = t0.iter().map(|(k, _, u, s)| (*k, (*u, *s))).collect(); @@ -451,38 +481,12 @@ fn drive_live_fps( .map(|(n, (u, s))| format!("{n}:{:.2}+{:.2}", per(*u), per(*s))) .collect::>() .join(","); - let inside = main_split - .inside_ms() - .map(|m| format!(" main_in={m:.2}")) - .unwrap_or_default(); - let now_still = still_inputs_now(); - let noisy: Vec = now_still - .iter() - .zip(probe.still_at_start) - .map(|(n, s)| n.wrapping_sub(s)) - .collect(); - // Page faults per frame: `sys` time without syscalls is the kernel - // zero-filling pages. - let faults = - match (probe.faults_at_start, crate::perf::process_faults()) { - (Some((mi0, ma0)), Some((mi1, ma1))) => format!( - " faults={:.0}/{:.1} noisy=[cam:{},dbg:{},view:{},win:{},claim:{},portals:{},flips:{}]", - (mi1 - mi0) as f64 / v_len as f64, - (ma1 - ma0) as f64 / v_len as f64, - noisy[0], - noisy[1], - noisy[2], - noisy[3], - noisy[4], - noisy[5], - noisy[6], - ), - _ => String::new(), - }; - format!(" thr=[{list}]{inside}{faults}") + format!(" thr=[{list}]") } _ => String::new(), }; + threads.push_str(&inside); + threads.push_str(&faults); format!( " cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \ frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]{threads}", diff --git a/crates/benilla-app/src/perf/clock.rs b/crates/benilla-app/src/perf/clock.rs index 47226718..c1e96334 100644 --- a/crates/benilla-app/src/perf/clock.rs +++ b/crates/benilla-app/src/perf/clock.rs @@ -244,7 +244,37 @@ pub(crate) fn thread_cpu_table() -> Option> { } Some(out) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + // `/proc/self/task//stat`: one line per thread, the comm in parens then the fields + // from field 3 (state), so `utime` (field 14) and `stime` (field 15) sit at rest-index 11 + // and 12. The comm may contain spaces and parens, so split at the LAST `)`. A thread that + // dies between the readdir and its stat read is skipped. + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64; + if ticks <= 0.0 { + return None; + } + let mut out = Vec::new(); + for entry in std::fs::read_dir("/proc/self/task").ok()? { + let Ok(entry) = entry else { continue }; + let tid: usize = entry.file_name().to_string_lossy().parse().ok()?; + let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else { + continue; + }; + let Some(close) = stat.rfind(')') else { continue }; + let name = stat[..close].trim_start_matches('(').to_string(); + let fields: Vec<&str> = stat[close + 1..].split_whitespace().collect(); + let (Some(u), Some(s)) = (fields.get(11), fields.get(12)) else { + continue; + }; + let (Ok(u), Ok(s)) = (u.parse::(), s.parse::()) else { + continue; + }; + out.push((tid, name, u / ticks, s / ticks)); + } + Some(out) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { None }