Files
jyviko c5ccb9201d Wave 43/45: smlib: rustfmt after the C++ kernel's removal (and 1 more)
2 commits, squashed; the originals stay reachable from branch jyviko/merge-main-develop-solidcore (#11360).

- 19db2d7c0 jyviko: Docs and comments: solidcore is the kernel
- f1ee045de jyviko: smlib: rustfmt after the C++ kernel's removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
2026-11-02 18:09:38 -05:00

551 lines
18 KiB
Rust

//! Where SolidCraft spends its time: nested, timed spans on every thread, for SMLib's C API
//! calls (timed by the C API itself), kernel operations, feature evaluation, commands and the
//! stages of a STEP or IGES import.
//!
//! - **Compiled in** with the `enabled` feature, which every app turns on by default (its own
//! `profile` feature), so a release can be profiled; a build without it records nothing and
//! every call here is an empty inline function.
//! - **Off at run time** until [`set_enabled`] (`SOLIDCRAFT_PROFILE=1`, the CLI's `--profile`, the
//! app's preference, the `diagnostics.profile` command). Off, a span costs one relaxed atomic
//! load.
//! - **Two outputs:** an aggregate per span name ([`report`]: count, total, self time, longest),
//! kept for the whole run, and the individual spans as a Chrome trace-event file
//! ([`write_chrome_trace`], for chrome://tracing or Perfetto), capped at [`MAX_EVENTS`]
//! (later spans still count in the aggregate; the report says how many were dropped).
//!
//! A span's self time is its time less its children's on the same thread. Its total counts only
//! its outermost occurrence on a thread's stack, so a command run by a command, or a boolean
//! inside a boolean, is not counted twice. Work a span hands to other threads shows there, under
//! those threads' own spans.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::time::Duration;
/// The profiler is compiled into this build.
pub const COMPILED: bool = cfg!(any(feature = "enabled", test));
/// Spans kept for the trace; later ones count in the aggregate only.
pub const MAX_EVENTS: usize = 200_000;
/// Distinct names [`intern`] keeps; later new names are all [`OTHER`].
pub const MAX_NAMES: usize = 4096;
/// The name of a span whose own name did not fit in [`MAX_NAMES`].
pub const OTHER: &str = "other";
/// One span name's aggregate.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Row {
/// What the span is: `solidcore`, `kernel`, `feature`, `command`, `import`, …
pub category: &'static str,
pub name: &'static str,
/// Times the span ran.
pub count: u64,
/// Its time, counting only its outermost occurrence on each thread's stack.
pub total: Duration,
/// Its time less the time of the spans inside it.
pub self_time: Duration,
/// Its longest single run.
pub max: Duration,
}
/// What the profiler has recorded since it was last reset.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Report {
/// The profiler is compiled in.
pub compiled: bool,
/// It is recording now.
pub enabled: bool,
/// Wall time since the last reset (or since recording first started).
pub wall: Duration,
/// One row per span name, in no particular order.
pub rows: Vec<Row>,
/// Spans kept for the trace.
pub events: usize,
/// Spans past [`MAX_EVENTS`], left out of the trace (they are in the rows).
pub dropped: u64,
}
fn ms(d: Duration) -> f64 {
(d.as_secs_f64() * 1e6).round() / 1e3
}
impl Report {
/// The rows, the most self time first.
pub fn sorted(&self) -> Vec<Row> {
let mut rows = self.rows.clone();
rows.sort_by(|a, b| b.self_time.cmp(&a.self_time).then(b.total.cmp(&a.total)).then(a.name.cmp(b.name)));
rows
}
/// Self time per category, the most first: where the time went (SMLib or SolidCraft).
pub fn by_category(&self) -> Vec<(&'static str, Duration)> {
let mut out: Vec<(&'static str, Duration)> = Vec::new();
for r in &self.rows {
match out.iter_mut().find(|(c, _)| *c == r.category) {
Some((_, d)) => *d += r.self_time,
None => out.push((r.category, r.self_time)),
}
}
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
out
}
/// What was recorded after `earlier` (a report of the same run, taken before): counts and
/// times are the differences. `max` is the longest run of the whole recording, which may
/// be older than `earlier`.
pub fn since(&self, earlier: &Report) -> Report {
let rows = self
.rows
.iter()
.filter_map(|r| {
let before = earlier.rows.iter().find(|e| e.category == r.category && e.name == r.name);
let row = match before {
Some(e) => Row {
count: r.count.saturating_sub(e.count),
total: r.total.saturating_sub(e.total),
self_time: r.self_time.saturating_sub(e.self_time),
..*r
},
None => *r,
};
(row.count > 0).then_some(row)
})
.collect();
Report {
compiled: self.compiled,
enabled: self.enabled,
wall: self.wall.saturating_sub(earlier.wall),
rows,
events: self.events.saturating_sub(earlier.events),
dropped: self.dropped.saturating_sub(earlier.dropped),
}
}
/// The report as a text table with the `top` rows of most self time (0: all of them).
pub fn table(&self, top: usize) -> String {
use std::fmt::Write as _;
let mut s = String::new();
if !self.compiled {
s.push_str("profile: not compiled into this build (the `profile` feature is off)\n");
return s;
}
let spans = self.events as u64 + self.dropped;
let _ = writeln!(s, "profile: {:.1} ms wall, {spans} spans ({} not kept for the trace)", ms(self.wall), self.dropped);
let all: f64 = self.rows.iter().map(|r| r.self_time.as_secs_f64()).sum();
let parts: Vec<String> = self
.by_category()
.into_iter()
.map(|(c, d)| {
let share = if all > 0.0 { d.as_secs_f64() / all * 100.0 } else { 0.0 };
format!("{c} {:.1} ms ({share:.1}%)", ms(d))
})
.collect();
let _ = writeln!(s, "self time by category: {}", if parts.is_empty() { "nothing recorded".to_string() } else { parts.join(", ") });
let sorted = self.sorted();
let n = if top == 0 { sorted.len() } else { top.min(sorted.len()) };
let _ = writeln!(s, "{:>11} {:>11} {:>8} {:>10} span", "self ms", "total ms", "count", "max ms");
for r in sorted.iter().take(n) {
let _ = writeln!(s, "{:>11.3} {:>11.3} {:>8} {:>10.3} {} {}", ms(r.self_time), ms(r.total), r.count, ms(r.max), r.category, r.name);
}
if n < sorted.len() {
let _ = writeln!(s, "({} more span names)", sorted.len() - n);
}
s
}
}
/// A point in the recording to report from ([`Mark::report`]): an import's own share.
#[derive(Clone, Debug)]
pub struct Mark(Report);
/// Mark the recording here.
pub fn mark() -> Mark {
Mark(report())
}
impl Mark {
/// What was recorded since the mark (all threads: work running beside it counts too).
pub fn report(&self) -> Report {
report().since(&self.0)
}
}
/// A running span; it ends when dropped (also while a panic unwinds).
#[must_use = "a span measures until it is dropped"]
pub struct Span {
#[cfg(any(feature = "enabled", test))]
open: Option<imp::Open>,
}
impl Drop for Span {
#[inline]
fn drop(&mut self) {
#[cfg(any(feature = "enabled", test))]
if let Some(open) = self.open.take() {
imp::close(&open);
}
}
}
/// Start a span `name` of `category` on this thread, when recording.
#[inline]
pub fn span(category: &'static str, name: &'static str) -> Span {
#[cfg(any(feature = "enabled", test))]
{
Span { open: if enabled() { imp::open((category, name)) } else { None } }
}
#[cfg(not(any(feature = "enabled", test)))]
{
let _ = (category, name);
Span {}
}
}
/// Run `f` as a span.
#[inline]
pub fn scope<T>(category: &'static str, name: &'static str, f: impl FnOnce() -> T) -> T {
let _span = span(category, name);
f()
}
/// Record a span that just ended after `took`, timed by someone else (SMLib's C API times its
/// own calls): a child of the span running on this thread.
#[inline]
pub fn record(category: &'static str, name: &'static str, took: Duration) {
#[cfg(any(feature = "enabled", test))]
if enabled() {
imp::record((category, name), u64::try_from(took.as_nanos()).unwrap_or(u64::MAX));
}
#[cfg(not(any(feature = "enabled", test)))]
let _ = (category, name, took);
}
/// `name` as a name a span can take, kept for the rest of the run (at most [`MAX_NAMES`]
/// distinct ones; after that [`OTHER`]). For names only known at run time, such as SMLib's C
/// function names; spans named in code use literals.
pub fn intern(name: &str) -> &'static str {
#[cfg(any(feature = "enabled", test))]
{
imp::intern(name)
}
#[cfg(not(any(feature = "enabled", test)))]
{
let _ = name;
OTHER
}
}
/// Is the profiler recording?
#[inline]
pub fn enabled() -> bool {
#[cfg(any(feature = "enabled", test))]
{
imp::ON.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(not(any(feature = "enabled", test)))]
{
false
}
}
/// Start or stop recording (nothing happens when the profiler is not compiled in). What was
/// recorded stays until [`reset`].
pub fn set_enabled(on: bool) {
#[cfg(any(feature = "enabled", test))]
imp::set_enabled(on);
#[cfg(not(any(feature = "enabled", test)))]
let _ = on;
}
/// Forget what was recorded; the wall time starts again now.
pub fn reset() {
#[cfg(any(feature = "enabled", test))]
imp::reset();
}
/// What was recorded since the last reset.
pub fn report() -> Report {
#[cfg(any(feature = "enabled", test))]
{
imp::report()
}
#[cfg(not(any(feature = "enabled", test)))]
{
Report::default()
}
}
/// Write the kept spans as a Chrome trace-event file (JSON object format: complete events, one
/// track per thread), for chrome://tracing or Perfetto.
pub fn write_chrome_trace(w: &mut dyn std::io::Write) -> std::io::Result<()> {
#[cfg(any(feature = "enabled", test))]
{
imp::write_chrome_trace(w)
}
#[cfg(not(any(feature = "enabled", test)))]
{
w.write_all(br#"{"displayTimeUnit":"ms","traceEvents":[]}"#)
}
}
#[cfg(any(feature = "enabled", test))]
mod imp {
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::Duration;
use serde_json::json;
use web_time::Instant;
use super::{MAX_EVENTS, MAX_NAMES, OTHER, Report, Row};
pub(super) static ON: AtomicBool = AtomicBool::new(false);
type Key = (&'static str, &'static str);
/// Deeper spans on one thread are not recorded (a runaway recursion stays bounded).
const MAX_DEPTH: usize = 512;
/// Threads named in the trace.
const MAX_THREADS: usize = 4096;
#[derive(Clone, Copy, Default)]
struct Agg {
count: u64,
total_ns: u64,
self_ns: u64,
max_ns: u64,
}
#[derive(Clone, Copy)]
struct Event {
key: Key,
tid: u32,
start_ns: u64,
dur_ns: u64,
}
#[derive(Default)]
struct Global {
agg: HashMap<Key, Agg>,
events: Vec<Event>,
dropped: u64,
threads: Vec<(u32, String)>,
next_tid: u32,
since_ns: u64,
}
static GLOBAL: LazyLock<Mutex<Global>> = LazyLock::new(|| Mutex::new(Global::default()));
static NAMES: LazyLock<Mutex<HashMap<String, &'static str>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn global() -> MutexGuard<'static, Global> {
// A panic while the lock was held leaves plain data behind: keep using it.
GLOBAL.lock().unwrap_or_else(PoisonError::into_inner)
}
fn epoch() -> Instant {
static EPOCH: OnceLock<Instant> = OnceLock::new();
*EPOCH.get_or_init(Instant::now)
}
fn now_ns() -> u64 {
u64::try_from(epoch().elapsed().as_nanos()).unwrap_or(u64::MAX)
}
struct Frame {
key: Key,
child_ns: u64,
}
thread_local! {
static STACK: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
/// This thread's number in the trace (0: not given one yet).
static TID: Cell<u32> = const { Cell::new(0) };
}
pub(super) struct Open {
key: Key,
start_ns: u64,
depth: usize,
}
pub(super) fn open(key: Key) -> Option<Open> {
let depth = STACK
.try_with(|s| {
let mut s = s.try_borrow_mut().ok()?;
if s.len() >= MAX_DEPTH {
return None;
}
s.push(Frame { key, child_ns: 0 });
Some(s.len() - 1)
})
.ok()
.flatten()?;
Some(Open { key, start_ns: now_ns(), depth })
}
pub(super) fn close(o: &Open) {
let dur = now_ns().saturating_sub(o.start_ns);
let (child, outermost) = STACK
.try_with(|s| {
let Ok(mut s) = s.try_borrow_mut() else { return (0, true) };
let child = s.get(o.depth).map_or(0, |f| f.child_ns);
// Spans opened inside this one and not closed yet (leaked) end with it.
s.truncate(o.depth);
let outermost = !s.iter().any(|f| f.key == o.key);
if let Some(parent) = s.last_mut() {
parent.child_ns = parent.child_ns.saturating_add(dur);
}
(child, outermost)
})
.unwrap_or((0, true));
add(o.key, o.start_ns, dur, dur.saturating_sub(child), outermost);
}
pub(super) fn record(key: Key, dur: u64) {
let start = now_ns().saturating_sub(dur);
let outermost = STACK
.try_with(|s| {
let Ok(mut s) = s.try_borrow_mut() else { return true };
let outermost = !s.iter().any(|f| f.key == key);
if let Some(parent) = s.last_mut() {
parent.child_ns = parent.child_ns.saturating_add(dur);
}
outermost
})
.unwrap_or(true);
add(key, start, dur, dur, outermost);
}
fn thread_id(g: &mut Global) -> u32 {
TID.try_with(|t| {
if t.get() == 0 {
g.next_tid = g.next_tid.saturating_add(1);
t.set(g.next_tid);
if g.threads.len() < MAX_THREADS {
g.threads.push((g.next_tid, thread_name(g.next_tid)));
}
}
t.get()
})
.unwrap_or(0)
}
#[cfg(not(target_arch = "wasm32"))]
fn thread_name(tid: u32) -> String {
std::thread::current().name().map_or_else(|| format!("thread {tid}"), str::to_string)
}
#[cfg(target_arch = "wasm32")]
fn thread_name(_: u32) -> String {
"main".into()
}
fn add(key: Key, start_ns: u64, dur_ns: u64, self_ns: u64, outermost: bool) {
let mut g = global();
let tid = thread_id(&mut g);
let a = g.agg.entry(key).or_default();
a.count = a.count.saturating_add(1);
if outermost {
a.total_ns = a.total_ns.saturating_add(dur_ns);
}
a.self_ns = a.self_ns.saturating_add(self_ns);
a.max_ns = a.max_ns.max(dur_ns);
if g.events.len() < MAX_EVENTS {
g.events.push(Event { key, tid, start_ns, dur_ns });
} else {
g.dropped = g.dropped.saturating_add(1);
}
}
pub(super) fn intern(name: &str) -> &'static str {
let mut names = NAMES.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(n) = names.get(name) {
return n;
}
if names.len() >= MAX_NAMES {
return OTHER;
}
let kept: &'static str = Box::leak(name.to_string().into_boxed_str());
names.insert(name.to_string(), kept);
kept
}
pub(super) fn set_enabled(on: bool) {
let was = ON.swap(on, Ordering::Relaxed);
if on && !was {
let now = now_ns();
let mut g = global();
// The wall time starts with the first recording.
if g.agg.is_empty() {
g.since_ns = now;
}
}
}
pub(super) fn reset() {
let now = now_ns();
let mut g = global();
g.agg.clear();
g.events = Vec::new();
g.dropped = 0;
g.since_ns = now;
}
pub(super) fn report() -> Report {
let now = now_ns();
let g = global();
let d = Duration::from_nanos;
let rows = g
.agg
.iter()
.map(|(k, a)| Row { category: k.0, name: k.1, count: a.count, total: d(a.total_ns), self_time: d(a.self_ns), max: d(a.max_ns) })
.collect();
Report {
compiled: true,
enabled: ON.load(Ordering::Relaxed),
wall: d(now.saturating_sub(g.since_ns)),
rows,
events: g.events.len(),
dropped: g.dropped,
}
}
pub(super) fn write_chrome_trace(w: &mut dyn std::io::Write) -> std::io::Result<()> {
// Copied out, so spans closing on other threads don't wait for the file.
let (events, threads, dropped) = {
let g = global();
(g.events.clone(), g.threads.clone(), g.dropped)
};
let us = |ns: u64| ns as f64 / 1000.0;
write!(w, "{{\"displayTimeUnit\":\"ms\",\"otherData\":{{\"dropped\":{dropped}}},\"traceEvents\":[")?;
let mut first = true;
let mut sep = |w: &mut dyn std::io::Write| -> std::io::Result<()> {
if !first {
w.write_all(b",\n")?;
}
first = false;
Ok(())
};
sep(w)?;
serde_json::to_writer(&mut *w, &json!({"ph": "M", "name": "process_name", "pid": 1, "tid": 0, "args": {"name": "SolidCraft"}}))?;
for (tid, name) in &threads {
sep(w)?;
serde_json::to_writer(&mut *w, &json!({"ph": "M", "name": "thread_name", "pid": 1, "tid": tid, "args": {"name": name}}))?;
}
for e in &events {
sep(w)?;
serde_json::to_writer(
&mut *w,
&json!({"ph": "X", "cat": e.key.0, "name": e.key.1, "pid": 1, "tid": e.tid, "ts": us(e.start_ns), "dur": us(e.dur_ns)}),
)?;
}
w.write_all(b"]}\n")
}
}
#[cfg(test)]
mod tests;