harn_vm/builtin_profile.rs
1//! Per-builtin wall-time attribution.
2//!
3//! The categorical [`crate::profile`] rollup answers "LLM vs tools vs steps",
4//! which is the right question for an agent run and the wrong one for a script
5//! that is simply slow: every builtin a pipeline calls — a project scan, a
6//! subprocess, a file read — lands in `vm/residual` alongside the script's own
7//! bytecode. A run that spends ten seconds inside one builtin reports
8//! `vm/residual 100.0%`, which names nothing.
9//!
10//! Builtins are called far too often to afford a [`crate::tracing::Span`] each
11//! (a string name, a metadata map, a vector push). This records two integers per
12//! builtin NAME instead — call count and total nanoseconds — behind an atomic
13//! that is only set when the operator asked for a profile. When it is off, the
14//! cost is one relaxed atomic load per builtin call.
15//!
16//! Two limits, stated so the table is not read for more than it says:
17//!
18//! Time is INCLUSIVE. A builtin that calls back into the VM (and so into other
19//! builtins) counts its callees' time as well as its own. That is the honest
20//! reading for the question this answers — "what is this run waiting on" — but
21//! it means the column does not sum to wall time, and the renderer says so.
22//!
23//! Each timed call carries its own measurement cost (a clock read and a lock),
24//! charged to the builtin being measured. That is noise against a builtin that
25//! takes ten seconds and a meaningful fraction of one that takes a microsecond,
26//! so this finds expensive builtins — it is not a microbenchmark of cheap ones.
27//! Rank the table; do not read a cheap builtin's per-call average as its true
28//! cost.
29
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32use std::sync::Mutex;
33use std::time::Duration;
34
35use serde::{Deserialize, Serialize};
36
37/// Whether to record. Read once per builtin call, so it stays an atomic rather
38/// than a lock.
39static ENABLED: AtomicBool = AtomicBool::new(false);
40
41static TOTALS: Mutex<Option<HashMap<String, BuiltinTotal>>> = Mutex::new(None);
42
43#[derive(Debug, Clone, Copy, Default)]
44struct BuiltinTotal {
45 calls: u64,
46 nanos: u128,
47}
48
49/// One builtin's aggregated cost across a run.
50#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
51pub struct BuiltinBucket {
52 pub name: String,
53 pub total_ms: u64,
54 pub calls: u64,
55 /// Mean call cost. The interesting split is one slow call versus a million
56 /// fast ones, and a total alone cannot tell them apart.
57 pub avg_ms: f64,
58}
59
60/// Start recording, discarding anything from a previous run.
61///
62/// The recorder is enabled per RUN but lives for the PROCESS, so the returned
63/// guard ends the recording when the run that asked for it finishes. Without
64/// it an embedder that profiles one script and not the next would keep paying
65/// for bookkeeping nobody reads, and fold the second run's builtins into the
66/// first run's totals.
67///
68/// The lifecycle belongs to the caller and nowhere else. It used to also end
69/// at `reset_thread_local_state`, which runs from ~150 test setups and from
70/// production entry points like `execute_conformance_source`; any of them
71/// firing mid-run disarmed the recorder and the profile named nothing.
72#[must_use = "recording ends when the returned guard drops"]
73pub fn enable() -> RecordingGuard {
74 let generation = GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
75 *TOTALS.lock().unwrap_or_else(|e| e.into_inner()) = Some(HashMap::new());
76 ENABLED.store(true, Ordering::Relaxed);
77 RecordingGuard { generation }
78}
79
80/// Which `enable()` currently owns the recorder.
81///
82/// The recorder is one process-global, so two overlapping profiled runs cannot
83/// both have their own. The generation makes the overlap degrade predictably
84/// instead of silently: the later `enable()` takes ownership, and the earlier
85/// guard's drop becomes a no-op rather than disarming a run that is still
86/// going. Whoever holds the current generation ends the recording.
87static GENERATION: AtomicU64 = AtomicU64::new(0);
88
89/// Ends builtin recording when dropped, unless a later [`enable`] has since
90/// taken ownership of the recorder.
91pub struct RecordingGuard {
92 generation: u64,
93}
94
95impl Drop for RecordingGuard {
96 fn drop(&mut self) {
97 if GENERATION.load(Ordering::SeqCst) == self.generation {
98 reset();
99 }
100 }
101}
102
103pub fn is_enabled() -> bool {
104 ENABLED.load(Ordering::Relaxed)
105}
106
107/// Record one completed builtin call.
108///
109/// Allocates only the first time a given builtin is seen. `entry()` would take
110/// an owned key and so allocate on every call — and the cost of measuring a
111/// builtin is charged to that builtin, so a per-call allocation shows up as the
112/// hot spot instead of finding it. `len` called 200k times reported a second of
113/// wall time that was almost entirely this function.
114pub fn record(name: &str, elapsed: Duration) {
115 if !is_enabled() {
116 return;
117 }
118 let mut guard = TOTALS.lock().unwrap_or_else(|e| e.into_inner());
119 let Some(totals) = guard.as_mut() else {
120 return;
121 };
122 let nanos = elapsed.as_nanos();
123 if let Some(entry) = totals.get_mut(name) {
124 entry.calls += 1;
125 entry.nanos += nanos;
126 return;
127 }
128 totals.insert(name.to_string(), BuiltinTotal { calls: 1, nanos });
129}
130
131/// Times one builtin call and records it on drop.
132///
133/// Scope-guard shaped, like [`crate::vm::ScopeSpan`], so a dispatch path adopts
134/// it with a binding rather than a matched pair of calls it could return early
135/// between. A builtin that throws still reports the time it burned first.
136///
137/// Borrows the name rather than owning it: a dispatch path always has the name
138/// alive across the call it is timing, and copying it per call would charge the
139/// copy to the builtin being measured.
140pub struct BuiltinTimer<'a> {
141 name: &'a str,
142 start: std::time::Instant,
143}
144
145impl<'a> BuiltinTimer<'a> {
146 /// `None` when profiling is off, which costs one relaxed atomic load and
147 /// keeps `Instant::now` off the hot path of every `to_string` call.
148 pub fn start(name: &'a str) -> Option<Self> {
149 if !is_enabled() {
150 return None;
151 }
152 Some(Self {
153 name,
154 start: std::time::Instant::now(),
155 })
156 }
157}
158
159impl Drop for BuiltinTimer<'_> {
160 fn drop(&mut self) {
161 record(self.name, self.start.elapsed());
162 }
163}
164
165/// The recorded buckets, most expensive first. Empty when profiling was off.
166pub fn snapshot() -> Vec<BuiltinBucket> {
167 let guard = TOTALS.lock().unwrap_or_else(|e| e.into_inner());
168 let Some(totals) = guard.as_ref() else {
169 return Vec::new();
170 };
171 let mut buckets: Vec<BuiltinBucket> = totals
172 .iter()
173 .map(|(name, total)| {
174 let total_ms = (total.nanos / 1_000_000) as u64;
175 BuiltinBucket {
176 name: name.clone(),
177 total_ms,
178 calls: total.calls,
179 avg_ms: if total.calls == 0 {
180 0.0
181 } else {
182 (total.nanos as f64 / total.calls as f64) / 1_000_000.0
183 },
184 }
185 })
186 .collect();
187 // Ties broken by name so the rendered table is stable across runs.
188 buckets.sort_by(|a, b| {
189 b.total_ms
190 .cmp(&a.total_ms)
191 .then_with(|| a.name.cmp(&b.name))
192 });
193 buckets
194}
195
196/// Stop recording and drop the totals.
197pub fn reset() {
198 ENABLED.store(false, Ordering::Relaxed);
199 *TOTALS.lock().unwrap_or_else(|e| e.into_inner()) = None;
200}
201
202/// The one lock serializing tests that touch the process-global recorder.
203///
204/// Lives outside `mod tests` because the lifecycle regression for this module
205/// sits next to `reset_thread_local_state` — it asserts that a global reset
206/// does NOT disarm an in-flight profiled run — and both sides must hold the
207/// SAME lock or they race each other's `enable`/`reset` under cargo's test
208/// threads.
209#[cfg(test)]
210pub(crate) fn test_lock() -> &'static Mutex<()> {
211 static TEST_LOCK: Mutex<()> = Mutex::new(());
212 &TEST_LOCK
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 /// The global recorder is process-wide, so these run under one lock rather
220 /// than racing each other through `enable`/`reset`.
221 use super::test_lock as TEST_LOCK;
222
223 #[test]
224 fn records_nothing_until_enabled() {
225 let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
226 reset();
227 record("project_fingerprint", Duration::from_millis(10));
228 assert!(
229 snapshot().is_empty(),
230 "a run without --profile must not pay for bookkeeping it never reads"
231 );
232 }
233
234 #[test]
235 fn aggregates_calls_by_name_and_ranks_by_total() {
236 let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
237 let _recording = enable();
238 record("to_string", Duration::from_millis(1));
239 record("to_string", Duration::from_millis(3));
240 record("project_fingerprint", Duration::from_millis(120));
241 let buckets = snapshot();
242 reset();
243
244 assert_eq!(buckets.len(), 2);
245 // One slow call outranks several fast ones: the ranking is by cost, not
246 // by how busy a builtin looks.
247 assert_eq!(buckets[0].name, "project_fingerprint");
248 assert_eq!(buckets[0].total_ms, 120);
249 assert_eq!(buckets[0].calls, 1);
250 assert_eq!(buckets[1].name, "to_string");
251 assert_eq!(buckets[1].total_ms, 4);
252 assert_eq!(buckets[1].calls, 2);
253 assert!((buckets[1].avg_ms - 2.0).abs() < 0.001);
254 }
255
256 #[test]
257 fn enable_discards_a_previous_run() {
258 let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
259 let _recording = enable();
260 record("run_shell", Duration::from_millis(5));
261 let _recording = enable();
262 let buckets = snapshot();
263 reset();
264 assert!(
265 buckets.is_empty(),
266 "a fresh run must not inherit stale totals"
267 );
268 }
269
270 /// Two profiled runs can overlap in one process — an embedder driving two
271 /// VMs, or a `--profile` run that starts while another is finishing. There
272 /// is only one recorder, so the later run owns it; the earlier run's guard
273 /// must not disarm it on the way out.
274 #[test]
275 fn an_outgoing_run_does_not_disarm_the_run_that_replaced_it() {
276 let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
277 let first = enable();
278 let second = enable();
279
280 drop(first);
281
282 assert!(
283 is_enabled(),
284 "the superseded run's guard must not stop the recorder"
285 );
286 record("run_shell", Duration::from_millis(5));
287 assert!(
288 !snapshot().is_empty(),
289 "the owning run must still be recording"
290 );
291
292 drop(second);
293 assert!(!is_enabled(), "the owning run's guard ends the recording");
294 }
295}