brep_render/run_trace.rs
1//! The history-replay TRACE: a native instrument that counts and times every
2//! `execute_history` this crate performs, tagged with the SITE that asked for
3//! it and the thread it ran on.
4//!
5//! The engine executes a history on the RUNNER (a background thread natively,
6//! a worker in the browser). Anything the main thread re-derives by running the
7//! SAME history again is a second full execution of the document — the kernel's
8//! incremental cache is per thread, and the wire-harness / assembly / PMI tails
9//! are not cached at all, so a tail sweeps once per thread that replays.
10//! Whether that is happening cannot be read off a frame time, so this module
11//! makes it observable in two ways.
12//!
13//! A running TALLY, always on, one entry per site: how many executions that
14//! site performed, how long they took, and what came back. It is what
15//! `perf_stats`'s `runs` block reports and `perf_reset` clears, so a test can
16//! bracket one edit and assert that it cost ONE execution — the property the
17//! frame time cannot state, because a duplicate replay and a slow machine look
18//! alike in milliseconds. The cost is one mutex lock per `execute_history`,
19//! which is a millisecond-scale operation.
20//!
21//! Two things to know before reading a tally. It is PROCESS-global, not
22//! per-session — several headless sessions in one process share it — so the
23//! only meaningful read is one bracketed by [`reset`]; an unbracketed `runs` is
24//! every execution since the process started. And a CANCELLED run contributes
25//! nothing: the runner thread is terminated rather than joined, so its span is
26//! never dropped and never folded in. A tally of zero after a cancel is the
27//! expected reading, not a lost measurement.
28//!
29//! A per-execution LINE on stderr, when the environment asks for it:
30//!
31//! ```sh
32//! BREP_RUN_TRACE=1 ./brep-app --mcp 2>trace.log
33//! ```
34//!
35//! ```text
36//! [run-trace] site=runner thread=ThreadId(2) ms=412.6 features=21 reused=20 errors=0
37//! ```
38//!
39//! `site=runner` is the one execution a run is supposed to cost; every other
40//! site on a different thread in the same edit is a duplicate replay. Both
41//! halves are a no-op on wasm, where there is no second thread to trace.
42
43/// One site's running total. `features`, `reused` and `errors` are summed over
44/// the executions that reported a result — a replay that is "free" because
45/// every feature errored out reads the same as a warm cache hit without them.
46#[derive(Debug, Clone, Copy, Default, PartialEq)]
47pub struct SiteTally {
48 pub count: u64,
49 pub ms: f64,
50 pub features: u64,
51 pub reused: u64,
52 pub errors: u64,
53}
54
55#[cfg(not(target_arch = "wasm32"))]
56mod native {
57 use super::SiteTally;
58 use std::collections::BTreeMap;
59 use std::sync::atomic::{AtomicU8, Ordering};
60 use std::sync::Mutex;
61 use std::time::Instant;
62
63 static ENABLED: AtomicU8 = AtomicU8::new(0);
64
65 /// A set of per-site totals. A `BTreeMap` keyed by the site's
66 /// `&'static str` so the report comes out in a stable order and a new site
67 /// needs no registration.
68 ///
69 /// A TYPE, not just the one static, so a unit test can count into a tally
70 /// of its own: the process-global [`TALLY`] is also where every history the
71 /// crate's other tests run folds (`pipeline.rs` opens `span("runner")`), and
72 /// a test that counted there read another test's run as its own (runner 3
73 /// against an expected 2, once in a gate).
74 pub struct Tally(Mutex<BTreeMap<&'static str, SiteTally>>);
75
76 impl Tally {
77 pub const fn new() -> Self {
78 Tally(Mutex::new(BTreeMap::new()))
79 }
80
81 fn fold(&self, site: &'static str, ms: f64, features: u64, reused: u64, errors: u64) {
82 if let Ok(mut map) = self.0.lock() {
83 let entry = map.entry(site).or_default();
84 entry.count += 1;
85 entry.ms += ms;
86 entry.features += features;
87 entry.reused += reused;
88 entry.errors += errors;
89 }
90 }
91
92 /// Every site with a total, in name order.
93 pub fn sites(&self) -> Vec<(&'static str, SiteTally)> {
94 match self.0.lock() {
95 Ok(map) => map.iter().map(|(site, t)| (*site, *t)).collect(),
96 Err(poisoned) => poisoned.into_inner().iter().map(|(site, t)| (*site, *t)).collect(),
97 }
98 }
99
100 pub fn clear(&self) {
101 match self.0.lock() {
102 Ok(mut map) => map.clear(),
103 Err(poisoned) => poisoned.into_inner().clear(),
104 }
105 }
106 }
107
108 /// The tally every [`span`] folds into.
109 static TALLY: Tally = Tally::new();
110
111 /// Whether `BREP_RUN_TRACE` is set to anything but `0` (read once). Only
112 /// the stderr line is gated on it; the tally is always kept.
113 pub fn enabled() -> bool {
114 match ENABLED.load(Ordering::Relaxed) {
115 0 => {
116 let on = std::env::var("BREP_RUN_TRACE").is_ok_and(|value| value != "0");
117 ENABLED.store(if on { 2 } else { 1 }, Ordering::Relaxed);
118 on
119 }
120 2 => true,
121 _ => false,
122 }
123 }
124
125 /// Every site that has executed a history since the last [`reset`], in name
126 /// order.
127 pub fn tally() -> Vec<(&'static str, SiteTally)> {
128 TALLY.sites()
129 }
130
131 /// Drop every site's totals — the counterpart of clearing the frame-timing
132 /// window, so a caller can bracket ONE interaction.
133 pub fn reset() {
134 TALLY.clear()
135 }
136
137 /// One traced execution: folds into the tally when dropped, and prints
138 /// `site`, the running thread and the elapsed milliseconds when the trace
139 /// is switched on.
140 pub struct Span {
141 sink: &'static Tally,
142 site: &'static str,
143 start: Instant,
144 features: u64,
145 reused: u64,
146 errors: u64,
147 reported: bool,
148 }
149
150 impl Span {
151 /// Record what the execution actually did — how many features came
152 /// back, how many of them the incremental cache replayed, and how many
153 /// failed.
154 pub fn result(&mut self, result: &brep_kernel::HistoryResult) {
155 self.features = result.results.len() as u64;
156 self.reused = result.results.iter().filter(|feature| feature.reused).count() as u64;
157 self.errors = result.results.iter().filter(|feature| feature.error.is_some()).count() as u64;
158 self.reported = true;
159 }
160 }
161
162 impl Drop for Span {
163 fn drop(&mut self) {
164 let ms = self.start.elapsed().as_secs_f64() * 1e3;
165 self.sink.fold(self.site, ms, self.features, self.reused, self.errors);
166 if enabled() {
167 let note = if self.reported {
168 format!(" features={} reused={} errors={}", self.features, self.reused, self.errors)
169 } else {
170 String::new()
171 };
172 eprintln!(
173 "[run-trace] site={} thread={:?} ms={ms:.1}{note}",
174 self.site,
175 std::thread::current().id(),
176 );
177 }
178 }
179 }
180
181 /// Open a trace span around an `execute_history` call. Always `Some`
182 /// natively — the tally is not gated on the environment variable; the
183 /// `Option` is the shape wasm needs.
184 pub fn span(site: &'static str) -> Option<Span> {
185 span_into(site, &TALLY)
186 }
187
188 /// A span that folds into `sink` instead of the process-global tally.
189 pub(crate) fn span_into(site: &'static str, sink: &'static Tally) -> Option<Span> {
190 Some(Span { sink, site, start: Instant::now(), features: 0, reused: 0, errors: 0, reported: false })
191 }
192}
193
194#[cfg(not(target_arch = "wasm32"))]
195pub use native::{reset, span, tally, Tally};
196
197/// The wasm stand-in: there is no second thread to trace and no `std::time`.
198#[cfg(target_arch = "wasm32")]
199pub struct Span;
200
201#[cfg(target_arch = "wasm32")]
202impl Span {
203 pub fn result(&mut self, _result: &brep_kernel::HistoryResult) {}
204}
205
206#[cfg(target_arch = "wasm32")]
207pub fn span(_site: &'static str) -> Option<Span> {
208 None
209}
210
211#[cfg(target_arch = "wasm32")]
212pub fn tally() -> Vec<(&'static str, SiteTally)> {
213 Vec::new()
214}
215
216#[cfg(target_arch = "wasm32")]
217pub fn reset() {}
218
219/// The tally as JSON: one object per site plus a `total`, the shape
220/// `perf_stats` embeds under `runs`.
221pub fn tally_json() -> serde_json::Value {
222 json_of(tally())
223}
224
225/// [`tally_json`] of any tally's sites.
226fn json_of(tally: Vec<(&'static str, SiteTally)>) -> serde_json::Value {
227 let mut sites = serde_json::Map::new();
228 let mut total = SiteTally::default();
229 for (site, t) in tally {
230 total.count += t.count;
231 total.ms += t.ms;
232 total.features += t.features;
233 total.reused += t.reused;
234 total.errors += t.errors;
235 sites.insert(
236 site.to_string(),
237 serde_json::json!({
238 "count": t.count,
239 "ms": (t.ms * 10.0).round() / 10.0,
240 "features": t.features,
241 "reused": t.reused,
242 "errors": t.errors,
243 }),
244 );
245 }
246 serde_json::json!({
247 "sites": serde_json::Value::Object(sites),
248 "total": {
249 "count": total.count,
250 "ms": (total.ms * 10.0).round() / 10.0,
251 "features": total.features,
252 "reused": total.reused,
253 "errors": total.errors,
254 },
255 })
256}
257